@moku-labs/game 0.15.27 → 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.
@@ -5872,7 +5872,7 @@ function restorePosition(ctx, _modules, bookmark) {
5872
5872
  * @param view - The last view.
5873
5873
  * @returns True while nothing the state shows moved.
5874
5874
  */
5875
- function isCurrent$1(ctx, view) {
5875
+ function isCurrent$2(ctx, view) {
5876
5876
  const runner = ctx.state.runner;
5877
5877
  return view.running === (runner.running !== void 0) && view.depth === runner.stack.length && view.top === runner.stack.at(-1) && view.open === ctx.state.gate.open && view.mode === ctx.state.fx.mode && view.journalIndex === runner.journalIndex;
5878
5878
  }
@@ -5913,7 +5913,7 @@ function viewOf(ctx) {
5913
5913
  function readState(ctx) {
5914
5914
  const runner = ctx.state.runner;
5915
5915
  const view = runner.view;
5916
- if (view !== void 0 && isCurrent$1(ctx, view)) return view.state;
5916
+ if (view !== void 0 && isCurrent$2(ctx, view)) return view.state;
5917
5917
  const next = viewOf(ctx);
5918
5918
  runner.view = next;
5919
5919
  return next.state;
@@ -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,10 +11270,12 @@ 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
  }
11264
11277
  mounted.items = next;
11278
+ mounted.seen = snapshot;
11265
11279
  if (options.direct) flushMounted(pctx, mounted);
11266
11280
  if (options.converge) convergeMounted(pctx, mounted);
11267
11281
  else {
@@ -11271,8 +11285,60 @@ function reconcileOne(pctx, spec, mounted, snapshot, options, counts, used) {
11271
11285
  counts.queued += mounted.queue.length;
11272
11286
  }
11273
11287
  /**
11274
- * Runs one reconcile over every mounted projection, or over the named ones only. A converging
11275
- * one then brings the elements other plugins own home and tells their owners.
11288
+ * Tells whether a play reconcile would change nothing in a projection: its last reconcile read the same `player` and
11289
+ * `session` trees, and nothing forces, converges or settles it. A scene mounts its projections direct after the commit
11290
+ * of its node; the next frame's reconcile of that commit then skips them (≈2 ms a switch on a Galaxy A26, F61).
11291
+ *
11292
+ * @param mounted - The mounted projection.
11293
+ * @param snapshot - The state of this reconcile.
11294
+ * @param options - How this reconcile runs.
11295
+ * @returns True when the projection is passed over.
11296
+ */
11297
+ function isCurrent$1(mounted, snapshot, options) {
11298
+ const seen = mounted.seen;
11299
+ return seen !== void 0 && !options.direct && !options.force && !options.converge && !options.settle && seen.player === snapshot.player && seen.session === snapshot.session;
11300
+ }
11301
+ /**
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.
11276
11342
  *
11277
11343
  * @param pctx - Domain context of the projection module.
11278
11344
  * @param options - Which projections, play or direct, whether `view` is forced, whether every
@@ -11284,12 +11350,20 @@ function reconcile(pctx, options) {
11284
11350
  const snapshot = pctx.ctx.deps.model.store.snapshot();
11285
11351
  const counts = emptyCounts();
11286
11352
  const used = emptyHintSet();
11287
- for (const [name, mounted] of state.mounted) {
11288
- if (options.names !== void 0 && !options.names.includes(name)) continue;
11289
- const spec = state.specs.get(name);
11290
- if (spec === void 0) continue;
11291
- counts.projections += 1;
11292
- 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);
11293
11367
  }
11294
11368
  if (options.converge) {
11295
11369
  convergeElements(pctx);
@@ -11305,9 +11379,7 @@ function reconcile(pctx, options) {
11305
11379
  //#endregion
11306
11380
  //#region src/plugins/world/projection/mount.ts
11307
11381
  /**
11308
- * Creates the empty view tables of a freshly mounted projection. These live in their own
11309
- * non-exported functions because lint rule L5 refuses a collection built inside an exported
11310
- * declaration.
11382
+ * Creates the empty view tables of a freshly mounted projection.
11311
11383
  *
11312
11384
  * @param owner - Who owns the entities of this projection.
11313
11385
  * @param order - The `Order` its screen roots draw at.
@@ -11325,14 +11397,29 @@ function emptyMounted(owner, order, layers) {
11325
11397
  };
11326
11398
  }
11327
11399
  /**
11328
- * 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.
11329
11416
  *
11330
- * @param pctx - Domain context of the projection module.
11331
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.
11332
11419
  * @throws {Error} When `layer` or `lift` is not in the layer list.
11333
11420
  */
11334
- function checkLayers(pctx, spec) {
11335
- 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));
11336
11423
  for (const name of [spec.layer, spec.lift]) {
11337
11424
  if (name === void 0 || declared.has(name)) continue;
11338
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.`);
@@ -11389,16 +11476,17 @@ function unmountNames(pctx, names) {
11389
11476
  }
11390
11477
  }
11391
11478
  /**
11392
- * The first pass of a mount, which touches nothing: every name is checked, a projection mounted
11393
- * into this scene already is reported and passed over, and the rest is sorted into the ones that
11394
- * 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.
11395
11482
  *
11396
11483
  * @param pctx - Domain context of the projection module.
11397
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.
11398
11486
  * @returns The specs to mount and reconcile, and the names to unmount first.
11399
- * @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.
11400
11488
  */
11401
- function sortMount(pctx, names) {
11489
+ function sortMount(pctx, names, layers) {
11402
11490
  const state = pctx.ctx.state.projection;
11403
11491
  const fresh = [];
11404
11492
  const again = [];
@@ -11406,12 +11494,12 @@ function sortMount(pctx, names) {
11406
11494
  const spec = state.specs.get(name);
11407
11495
  if (spec === void 0) throw new Error(`[game] Projection "${name}" is not registered.\n Register it before a scene mounts it.`);
11408
11496
  const mounted = state.mounted.get(name);
11409
- if (mounted?.layers === state.layers) {
11497
+ if (mounted?.layers === layers) {
11410
11498
  pctx.ctx.log.warn("world:already-mounted", { projection: name });
11411
11499
  continue;
11412
11500
  }
11413
- checkLayers(pctx, spec);
11414
- 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;
11415
11503
  if (mounted !== void 0) again.push(name);
11416
11504
  fresh.push(spec);
11417
11505
  }
@@ -11433,18 +11521,40 @@ function sortMount(pctx, names) {
11433
11521
  * @throws {Error} For an unknown name, or a `layer` or `lift` the scene does not declare.
11434
11522
  */
11435
11523
  function mountNames(pctx, names, owner) {
11436
- const state = pctx.ctx.state.projection;
11437
- const { fresh, again } = sortMount(pctx, names);
11524
+ const { fresh, again } = sortMount(pctx, names, pctx.ctx.state.projection.layers);
11438
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;
11439
11540
  for (const name of names) {
11440
- const order = names.indexOf(name) - names.length;
11541
+ const order = orderIn(names, name);
11441
11542
  const mounted = state.mounted.get(name);
11442
11543
  if (mounted === void 0) state.mounted.set(name, emptyMounted(owner, order, state.layers));
11443
11544
  else if (mounted.layers !== state.layers) placeMounted(pctx, mounted, order);
11444
11545
  }
11445
- 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;
11446
11556
  reconcile(pctx, {
11447
- names: fresh.map((spec) => spec.name),
11557
+ names,
11448
11558
  direct: true,
11449
11559
  force: true,
11450
11560
  converge: false,
@@ -11452,6 +11562,148 @@ function mountNames(pctx, names, owner) {
11452
11562
  });
11453
11563
  }
11454
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
11455
11707
  //#region src/plugins/world/projection/api.ts
11456
11708
  /**
11457
11709
  * Tells whether a cause sets the picture instead of playing it.
@@ -11626,7 +11878,7 @@ function createProjectionApi(ctx, deps) {
11626
11878
  replace: (spec) => {
11627
11879
  if (!state.specs.has(spec.name)) throw new Error(`[game] Projection "${spec.name}" is not registered.\n Register it before replacing it.`);
11628
11880
  const mounted = state.mounted.has(spec.name);
11629
- if (mounted) checkLayers(pctx, spec);
11881
+ if (mounted) checkLayers(spec, state.layers);
11630
11882
  state.specs.set(spec.name, spec);
11631
11883
  if (mounted) forceRerun();
11632
11884
  },
@@ -11701,7 +11953,7 @@ function createProjectionApi(ctx, deps) {
11701
11953
  motionsOf: (entity) => runningComponents(state.tracks, entity),
11702
11954
  entityOf: (projection, key) => state.mounted.get(projection)?.live.get(key)?.entity ?? state.keys.get(projection)?.get(key),
11703
11955
  entitiesOf: (name) => {
11704
- const mounted = state.mounted.get(name);
11956
+ const mounted = state.mounted.get(name) ?? state.building?.mounted.get(name);
11705
11957
  return mounted === void 0 ? [] : liveEntities(mounted);
11706
11958
  },
11707
11959
  setDriver: (driver) => {
@@ -11747,6 +11999,38 @@ function createProjectionApi(ctx, deps) {
11747
11999
  };
11748
12000
  },
11749
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
+ },
11750
12034
  markDirty: (roots, cause) => {
11751
12035
  const dirty = state.dirty ?? emptyDirty();
11752
12036
  dirty.causes.push(cause);
@@ -11785,6 +12069,7 @@ function createProjectionApi(ctx, deps) {
11785
12069
  if (mounted !== void 0) clearMounted(pctx, mounted);
11786
12070
  },
11787
12071
  clear: () => {
12072
+ discardBuild(pctx);
11788
12073
  for (const mounted of [...state.mounted.values()]) clearMounted(pctx, mounted);
11789
12074
  state.mounted.clear();
11790
12075
  state.specs.clear();
@@ -11893,8 +12178,8 @@ function exposeEcs(ecs, projection) {
11893
12178
  };
11894
12179
  }
11895
12180
  /**
11896
- * Reduces the projection module to what `scenes`, `input`, `i18n` and the `/inspect` door call.
11897
- * 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.
11898
12183
  *
11899
12184
  * @param projection - The full projection module.
11900
12185
  * @returns The public projection API.
@@ -11922,7 +12207,15 @@ function exposeProjection(projection) {
11922
12207
  restOf: projection.restOf,
11923
12208
  registerKey: projection.registerKey,
11924
12209
  onConverged: projection.onConverged,
11925
- 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
11926
12219
  };
11927
12220
  }
11928
12221
  /**
@@ -12065,7 +12358,8 @@ function emptyWeakMap$2() {
12065
12358
  }
12066
12359
  /**
12067
12360
  * Creates the initial projection state: nothing registered, nothing mounted, no layer list, no
12068
- * 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.
12069
12363
  *
12070
12364
  * @returns The projection branch of the plugin state.
12071
12365
  */
@@ -12091,7 +12385,8 @@ function createProjectionState() {
12091
12385
  membershipVersion: 0,
12092
12386
  moving: false,
12093
12387
  owedLoops: 0,
12094
- sweptDespawns: 0
12388
+ sweptDespawns: 0,
12389
+ buildChecks: []
12095
12390
  };
12096
12391
  }
12097
12392
  //#endregion
@@ -23548,7 +23843,7 @@ function heldFilters(target) {
23548
23843
  * @param slots - The slots to compare with.
23549
23844
  * @returns True when nothing has to be written.
23550
23845
  */
23551
- function holds(target, slots) {
23846
+ function holds$1(target, slots) {
23552
23847
  const held = heldFilters(target);
23553
23848
  let at = 0;
23554
23849
  for (let index = 0; index < slots.length; index += 1) {
@@ -23605,7 +23900,7 @@ function clearFilters(target) {
23605
23900
  * @param target - Where the filters hang: the view's top unless the kept cache says otherwise.
23606
23901
  */
23607
23902
  function applyFilters(view, slots, before, target = view.wrapper ?? view.object) {
23608
- if (holds(target, slots)) return;
23903
+ if (holds$1(target, slots)) return;
23609
23904
  before();
23610
23905
  writeFilters(target, slots);
23611
23906
  }
@@ -23717,7 +24012,7 @@ function applySwitchedOne(slots, entity) {
23717
24012
  const view = sync?.views.get(entity);
23718
24013
  if (view === void 0 || kept === void 0) return;
23719
24014
  const target = kept.filterTarget(view);
23720
- if (holds(target, slots)) return;
24015
+ if (holds$1(target, slots)) return;
23721
24016
  writeFilters(target, slots);
23722
24017
  switched.wrote = true;
23723
24018
  }
@@ -24628,6 +24923,348 @@ function clearFonts(state) {
24628
24923
  state.fontCache = void 0;
24629
24924
  }
24630
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
24631
25268
  //#region src/plugins/renderer/sync/outline.ts
24632
25269
  /** Above every child of the wrapper, whatever `Order` a child carries. */
24633
25270
  const OUTLINE_Z = Number.MAX_SAFE_INTEGER;
@@ -25056,293 +25693,6 @@ function labelOf$1(sctx, name, entity) {
25056
25693
  return key === void 0 ? base : `${base} ${key.projection}:${key.key}`;
25057
25694
  }
25058
25695
  //#endregion
25059
- //#region src/plugins/renderer/sync/pools.ts
25060
- /**
25061
- * Tells whether a display object is a sprite. A `NineSliceSprite` carries the slice widths and
25062
- * is recognised by them.
25063
- *
25064
- * @param object - The display object.
25065
- * @returns True for a plain sprite.
25066
- */
25067
- function isSprite(object) {
25068
- return "texture" in object && !("leftWidth" in object);
25069
- }
25070
- /**
25071
- * Tells whether a display object draws paths, so a `Shape` can be written onto it.
25072
- *
25073
- * @param object - The display object.
25074
- * @returns True for a graphics object.
25075
- */
25076
- function isGraphics(object) {
25077
- return "roundRect" in object;
25078
- }
25079
- /**
25080
- * Tells whether a display object draws a nine-slice: Pixi's `NineSliceSprite`, or the engine's rim-only mesh of a
25081
- * `hollow` one. Both carry the slice widths.
25082
- *
25083
- * @param object - The display object.
25084
- * @returns True for either nine-slice object.
25085
- */
25086
- function isNineSlice(object) {
25087
- return "leftWidth" in object;
25088
- }
25089
- /**
25090
- * Tells whether a display object is the engine's rim-only nine-slice (F59).
25091
- *
25092
- * @param object - The display object.
25093
- * @returns True for the hollow mesh, false for Pixi's `NineSliceSprite` and every other object.
25094
- */
25095
- function isHollow(object) {
25096
- return "hollow" in object;
25097
- }
25098
- /**
25099
- * Takes a display object out of the Pixi tree. Pixi containers are not DOM nodes, so the DOM
25100
- * helper the linter suggests does not exist on them.
25101
- *
25102
- * @param object - The display object.
25103
- */
25104
- function detach$1(object) {
25105
- object.parent?.removeChild(object);
25106
- }
25107
- /**
25108
- * Takes the masked container off a view: its children go back to the wrapper, unmasked and in
25109
- * their order, and the container is freed.
25110
- *
25111
- * @param view - The view of a shape that clips, or of anything that does not.
25112
- */
25113
- function dropClipped(view) {
25114
- const clipped = view.clipped;
25115
- if (clipped === void 0) return;
25116
- clipped.mask = null;
25117
- const wrapper = view.wrapper;
25118
- if (wrapper !== void 0) for (const child of clipped.children) wrapper.addChild(child);
25119
- detach$1(clipped);
25120
- clipped.destroy();
25121
- view.clipped = void 0;
25122
- }
25123
- /**
25124
- * Takes the clip rectangle off a view: the children stop being masked and the graphics goes.
25125
- *
25126
- * @param view - The view of a shape that clips, or of anything that does not.
25127
- */
25128
- function dropMask(view) {
25129
- dropClipped(view);
25130
- const mask = view.mask;
25131
- if (mask === void 0) return;
25132
- detach$1(mask);
25133
- mask.destroy();
25134
- view.mask = void 0;
25135
- }
25136
- /**
25137
- * Takes the debug outline off a view: the graphics leaves the wrapper and is freed.
25138
- *
25139
- * @param view - The view of a nine-slice that was outlined, or of anything that was not.
25140
- */
25141
- function dropOutline(view) {
25142
- const outline = view.outline;
25143
- if (outline === void 0) return;
25144
- detach$1(outline);
25145
- outline.destroy();
25146
- view.outline = void 0;
25147
- }
25148
- /**
25149
- * 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
25150
- * a filled panel never gets a hollow object back, nor the other way round.
25151
- *
25152
- * @param kind - Which component gave the entity its display object.
25153
- * @param textureKey - The asset key.
25154
- * @param hollow - True for the rim-only object of a `NineSlice` with `hollow: true`.
25155
- * @returns The pool key.
25156
- * @example
25157
- * ```ts
25158
- * poolKeyOf("NineSlice", "glass.frost", true); // "NineSlice.hollow:glass.frost"
25159
- * ```
25160
- */
25161
- function poolKeyOf(kind, textureKey, hollow) {
25162
- return hollow ? `${kind}.hollow:${textureKey}` : `${kind}:${textureKey}`;
25163
- }
25164
- /**
25165
- * Brings the object of a view that left back to the state a fresh one has: detached, drawn, opaque, unmoved, blending
25166
- * normally, white.
25167
- * Its hidden reasons go with `clearHidden`, so a pooled object never brings the reason of the view it belonged to; a
25168
- * sprite and a graphics lose their tint (a fill-only shape is coloured by it, spec 26 R2.1).
25169
- *
25170
- * @param view - The view whose object goes back to its pool; its wrapper is gone already.
25171
- */
25172
- function resetObject(view) {
25173
- detach$1(view.object);
25174
- clearHidden(view);
25175
- resetNode(view.object);
25176
- }
25177
- /**
25178
- * Brings a detached object back to a fresh one's pose and look: drawn, opaque, unmoved, blending as its parent (no
25179
- * glow of the view it drew for), white for a sprite and a graphics.
25180
- *
25181
- * @param object - The object, out of the tree.
25182
- */
25183
- function resetNode(object) {
25184
- object.visible = true;
25185
- object.alpha = 1;
25186
- object.blendMode = "inherit";
25187
- object.rotation = 0;
25188
- object.zIndex = 0;
25189
- object.position.set(0, 0);
25190
- object.pivot.set(0, 0);
25191
- object.scale.set(1);
25192
- if (isSprite(object) || isGraphics(object)) object.tint = 16777215;
25193
- }
25194
- /**
25195
- * Takes an object out of its pool, if one is waiting.
25196
- *
25197
- * @param state - The sync branch of the plugin state.
25198
- * @param poolKey - Class and texture key.
25199
- * @returns A reusable object, or `undefined`.
25200
- */
25201
- function acquire(state, poolKey) {
25202
- const object = state.pools.get(poolKey)?.pop();
25203
- if (object !== void 0) state.pooled -= 1;
25204
- return object;
25205
- }
25206
- /**
25207
- * Creates the array a pool keeps its objects in. Lint rule L5 refuses a collection built inside
25208
- * an exported declaration, so it lives here.
25209
- *
25210
- * @returns An empty pool.
25211
- */
25212
- function emptyPool() {
25213
- return [];
25214
- }
25215
- /**
25216
- * The pool that holds the object that has waited longest.
25217
- *
25218
- * @param state - The sync branch of the plugin state.
25219
- * @returns A non-empty pool, or `undefined` when every pool is empty.
25220
- */
25221
- function oldestPool(state) {
25222
- for (const pool of state.pools.values()) if (pool.length > 0) return pool;
25223
- }
25224
- /**
25225
- * Puts a view's object back into its pool and evicts the oldest one when the pool grew past the
25226
- * configured limit. A `Display` object belongs to the game and is only detached. The filters and the
25227
- * filter area the renderer hung on the object or its wrapper come off first, so a pooled object never
25228
- * carries a filter into its next life; the filter instances themselves belong to `effects`.
25229
- *
25230
- * @param sctx - Domain context of the sync module.
25231
- * @param view - The view that left.
25232
- */
25233
- function release$1(sctx, view) {
25234
- dropMask(view);
25235
- dropOutline(view);
25236
- clearFilters(view.object);
25237
- clearArea(view.object);
25238
- if (view.wrapper !== void 0) {
25239
- clearFilters(view.wrapper);
25240
- clearArea(view.wrapper);
25241
- detach$1(view.wrapper);
25242
- view.wrapper.destroy({ children: false });
25243
- view.wrapper = void 0;
25244
- }
25245
- if (view.kind === "Display") {
25246
- detach$1(view.object);
25247
- clearHidden(view);
25248
- return;
25249
- }
25250
- if (view.kind === "adapter") {
25251
- detach$1(view.object);
25252
- clearHidden(view);
25253
- view.display?.adapter.destroy(view.object);
25254
- return;
25255
- }
25256
- dropGroup(sctx, view.object);
25257
- resetObject(view);
25258
- store$1(sctx, view.object, view.poolKey);
25259
- }
25260
- /**
25261
- * Puts an object a view no longer draws with back into a pool, while the view goes on with another one: the
25262
- * nine-slice whose `hollow` flipped (F59). Reset as `release` resets a view's object; its view keeps its hidden
25263
- * reasons, which hang on the view's wrapper.
25264
- *
25265
- * @param sctx - Domain context of the sync module.
25266
- * @param object - The object, still in the view's wrapper.
25267
- * @param poolKey - The pool it belongs to.
25268
- */
25269
- function releaseObject(sctx, object, poolKey) {
25270
- dropGroup(sctx, object);
25271
- detach$1(object);
25272
- resetNode(object);
25273
- store$1(sctx, object, poolKey);
25274
- }
25275
- /**
25276
- * Takes away the render group the kept cache made of an object (spec 23 P9.3), before it is pooled.
25277
- *
25278
- * @param sctx - Domain context of the sync module.
25279
- * @param object - The object.
25280
- */
25281
- function dropGroup(sctx, object) {
25282
- const groups = sctx.ctx.state.kept.madeGroups;
25283
- if (!groups.has(object)) return;
25284
- object.disableRenderGroup();
25285
- groups.delete(object);
25286
- }
25287
- /**
25288
- * Pushes a reset object into its pool and evicts the one that waited longest while the pools hold more than
25289
- * `poolLimit`.
25290
- *
25291
- * @param sctx - Domain context of the sync module.
25292
- * @param object - The reset object.
25293
- * @param poolKey - Its pool.
25294
- */
25295
- function store$1(sctx, object, poolKey) {
25296
- const state = sctx.ctx.state.sync;
25297
- const pool = state.pools.get(poolKey) ?? emptyPool();
25298
- pool.push(object);
25299
- state.pools.set(poolKey, pool);
25300
- state.pooled += 1;
25301
- while (state.pooled > sctx.ctx.config.poolLimit) {
25302
- const oldest = oldestPool(state);
25303
- if (oldest === void 0) return;
25304
- oldest.shift()?.destroy({ texture: false });
25305
- state.pooled -= 1;
25306
- }
25307
- }
25308
- /**
25309
- * Destroys the pooled objects that draw one of these keys, so a bundle that left frees its GPU
25310
- * memory instead of waiting for a reuse that resolves nothing: sprites, nine-slices and rim-only nine-slices.
25311
- *
25312
- * @param state - The sync branch of the plugin state.
25313
- * @param keys - The asset keys that went stale.
25314
- */
25315
- function dropPooled(state, keys) {
25316
- for (const key of keys) {
25317
- dropPool(state, poolKeyOf("Sprite", key, false));
25318
- dropPool(state, poolKeyOf("NineSlice", key, false));
25319
- dropPool(state, poolKeyOf("NineSlice", key, true));
25320
- }
25321
- }
25322
- /**
25323
- * Destroys one pool and everything in it.
25324
- *
25325
- * @param state - The sync branch of the plugin state.
25326
- * @param poolKey - The pool.
25327
- */
25328
- function dropPool(state, poolKey) {
25329
- const pool = state.pools.get(poolKey);
25330
- if (pool === void 0) return;
25331
- for (const object of pool) object.destroy({ texture: false });
25332
- state.pooled -= pool.length;
25333
- state.pools.delete(poolKey);
25334
- }
25335
- /**
25336
- * Destroys everything the pools hold. Works on the state alone, because `onStop` has no context.
25337
- *
25338
- * @param state - The sync branch of the plugin state.
25339
- */
25340
- function destroyPools(state) {
25341
- for (const pool of state.pools.values()) for (const object of pool) object.destroy({ texture: false });
25342
- state.pools.clear();
25343
- state.pooled = 0;
25344
- }
25345
- //#endregion
25346
25696
  //#region src/plugins/renderer/sync/layers.ts
25347
25697
  /**
25348
25698
  * @file renderer/sync — the layers. `scenes` declares them through `world.projection.setLayers`;
@@ -28047,7 +28397,9 @@ function keepOrphan(sctx, entity, view, parentEntity) {
28047
28397
  /**
28048
28398
  * Hangs a view where it belongs: under its parent, or in the container of the layer it names.
28049
28399
  * An entity with no layer and no parent is not drawn. A view whose parent has no view yet waits
28050
- * 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.
28051
28403
  *
28052
28404
  * @param sctx - Domain context of the sync module.
28053
28405
  * @param entity - The entity.
@@ -28058,6 +28410,11 @@ function attach$1(sctx, entity, view) {
28058
28410
  const parentEntity = parentOf$1(sctx.ctx.deps.world.ecs, entity);
28059
28411
  markView(sctx, entity);
28060
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;
28061
28418
  if (parentEntity !== 0) {
28062
28419
  const host = childHostOf(sctx, parentEntity);
28063
28420
  view.layer = "";
@@ -28185,7 +28542,8 @@ function createView(sctx, entity) {
28185
28542
  clipDrawn: void 0,
28186
28543
  hiddenSelf: 0,
28187
28544
  hiddenTop: 0,
28188
- hud: 1
28545
+ hud: 1,
28546
+ held: false
28189
28547
  };
28190
28548
  object.label = labelOf$1(sctx, nameOf$1(visual), entity);
28191
28549
  state.views.set(entity, view);
@@ -28286,10 +28644,10 @@ function dropView(sctx, entity) {
28286
28644
  }
28287
28645
  /**
28288
28646
  * Drops every view, every layer, every pool, every filter slot and filter area, the warm-up (its waiting promises
28289
- * answer false, its probe tree is destroyed) and the upload queue (its waiting calls answer false). Works on the state
28290
- * alone,
28291
- * because `onStop` has no context. A `Display` object belongs to the game: it is detached, never
28292
- * 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.
28293
28651
  *
28294
28652
  * @param state - The sync branch of the plugin state.
28295
28653
  */
@@ -28335,6 +28693,7 @@ function stopSync(state) {
28335
28693
  stopWarm(state);
28336
28694
  stopUploads(state);
28337
28695
  state.held.clear();
28696
+ forgetBuild(state);
28338
28697
  state.redraws = 0;
28339
28698
  state.overdraw.listeners.length = 0;
28340
28699
  clearLayers(state);
@@ -28478,8 +28837,7 @@ function withDeps$8(ctx) {
28478
28837
  * overlay step (spec 22), then `kept` reads the marks before `demand` decides whether the frame is
28479
28838
  * drawn and clears them; a drawn frame draws the probes of the warm-up onto the canvas first (F4: the
28480
28839
  * stage pass clears them, in the same task, so they never show), then uploads the queued texture sources (F60), then
28481
- * the kept pictures
28482
- * (`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
28483
28841
  * oracle; `demand` settles the
28484
28842
  * frame (the dev check, the pixel oracle), and `monitor` closes it and hands a drawn one to a
28485
28843
  * waiting capture; last, `host` trims Pixi's texture pool when a rest or a scene change is due. The display objects
@@ -28490,7 +28848,8 @@ function withDeps$8(ctx) {
28490
28848
  * last picture (WebGPU presents a canvas texture only when one is rendered; a WebGL canvas keeps
28491
28849
  * what it composited until the next draw). A drawn frame is reported to `time` for the adaptive
28492
28850
  * frame rate, and only when Pixi drew it: an inert or lost host reports none. A frame that also drew
28493
- * 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.
28494
28853
  *
28495
28854
  * @param ctx - Domain context of the renderer plugin.
28496
28855
  * @param modules - The six modules.
@@ -28514,10 +28873,11 @@ function renderFrame(ctx, modules) {
28514
28873
  if (drawn) {
28515
28874
  const probed = modules.sync.warmStep();
28516
28875
  const uploaded = modules.sync.uploadStep();
28876
+ const built = modules.sync.takeBuildWork();
28517
28877
  modules.kept.flush();
28518
28878
  if (overdraw ? modules.sync.drawOverdraw(modules.host.render) : modules.host.render()) {
28519
28879
  ctx.deps.time.frameDrawn();
28520
- if (probed || uploaded) ctx.deps.time.frameProbed();
28880
+ if (probed || uploaded || built) ctx.deps.time.frameProbed();
28521
28881
  }
28522
28882
  modules.kept.after();
28523
28883
  }
@@ -29320,6 +29680,233 @@ function layerOf(sctx, entity) {
29320
29680
  return ecs.get(top, Layer)?.name;
29321
29681
  }
29322
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
29323
29910
  //#region src/plugins/renderer/sync/each.ts
29324
29911
  /**
29325
29912
  * Tells whether a change list is an array.
@@ -30212,7 +30799,7 @@ function expectedHostOf(sctx, entity, parent) {
30212
30799
  }
30213
30800
  /**
30214
30801
  * Checks where the views of the given entities hang and reports every one that hangs elsewhere than the ECS says,
30215
- * 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.
30216
30803
  *
30217
30804
  * @param sctx - Domain context of the sync module.
30218
30805
  * @param entities - The entities a pass built, hung again or took out of the orphans.
@@ -30222,7 +30809,7 @@ function checkTree(sctx, entities) {
30222
30809
  const ecs = sctx.ctx.deps.world.ecs;
30223
30810
  for (const entity of entities) {
30224
30811
  const view = state.views.get(entity);
30225
- 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;
30226
30813
  const parent = parentOf$1(ecs, entity);
30227
30814
  const host = (view.wrapper ?? view.object).parent;
30228
30815
  const expected = expectedHostOf(sctx, entity, parent);
@@ -30497,12 +31084,13 @@ function createSyncApi(ctx, deps) {
30497
31084
  const onParent = (entity) => stepOn(STEP.parent, entity);
30498
31085
  const onAdapter = (entity) => stepOn(STEP.adapter, entity);
30499
31086
  /**
30500
- * 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.
30501
31089
  *
30502
31090
  * @param entity - The entity.
30503
31091
  */
30504
31092
  const onDisplay = (entity) => {
30505
- if (built.has(entity)) return;
31093
+ if (built.has(entity) || state.aside.waiting.has(entity)) return;
30506
31094
  guard(entity, () => {
30507
31095
  dropView(sctx, entity);
30508
31096
  createView(sctx, entity);
@@ -30585,12 +31173,13 @@ function createSyncApi(ctx, deps) {
30585
31173
  };
30586
31174
  /**
30587
31175
  * Tells whether the pass has nothing to do: nothing waits to be built, dropped, hung or resolved again, the layer list
30588
- * 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.
30589
31178
  *
30590
31179
  * @returns True when the pass would change nothing.
30591
31180
  */
30592
31181
  const still = () => {
30593
- 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;
30594
31183
  const ecs = ctx.deps.world.ecs;
30595
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;
30596
31185
  for (let index = 0; index < state.adapters.length; index += 1) {
@@ -30625,13 +31214,16 @@ function createSyncApi(ctx, deps) {
30625
31214
  for (const [entity, view] of state.views) if (view.kind === "NineSlice") guard(entity, () => applyNineSlice(sctx, entity, view));
30626
31215
  };
30627
31216
  /**
30628
- * 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.
30629
31220
  *
30630
31221
  * @returns The entities of this pass, so the change step skips what the build just wrote.
30631
31222
  */
30632
31223
  const takeAdded = () => {
30633
31224
  const built = copyOf(state.added);
30634
31225
  state.added.clear();
31226
+ if (buildRuns(sctx)) sortAside(sctx, built);
30635
31227
  return built;
30636
31228
  };
30637
31229
  /**
@@ -30684,17 +31276,17 @@ function createSyncApi(ctx, deps) {
30684
31276
  return viewless;
30685
31277
  };
30686
31278
  /**
30687
- * One pass: removed, layers, added, changed, orphans, invalidated. A despawned entity takes its filter
30688
- * slots with it, also when its view left in an earlier pass; a live one keeps them for its next
30689
- * 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.
30690
31282
  */
30691
- const pass = () => {
30692
- if (still()) return;
31283
+ const livePass = () => {
30693
31284
  state.passesVersion += 1;
30694
31285
  if (state.removed.size > 0) {
30695
31286
  for (const entity of state.removed) {
30696
31287
  guard(entity, () => dropView(sctx, entity));
30697
31288
  forgetLeft(state, ctx.deps.world.ecs, entity);
31289
+ dropAside(state, entity);
30698
31290
  }
30699
31291
  state.removed.clear();
30700
31292
  }
@@ -30703,6 +31295,7 @@ function createSyncApi(ctx, deps) {
30703
31295
  deps.demand.whole("layers");
30704
31296
  deps.host.sceneChanged();
30705
31297
  }
31298
+ attachHeld(sctx);
30706
31299
  built = state.added.size === 0 ? nothingBuilt : buildParentFirst(takeAdded());
30707
31300
  const touched = typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ || typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__ ? touchedBy() : void 0;
30708
31301
  applyChanges();
@@ -30715,6 +31308,14 @@ function createSyncApi(ctx, deps) {
30715
31308
  if (touched !== void 0) checkTree(sctx, touched);
30716
31309
  };
30717
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
+ /**
30718
31319
  * Forgets every view, layer and pooled object without touching Pixi. Used before a rebuild,
30719
31320
  * where the old application and everything in it are already gone.
30720
31321
  */
@@ -30739,6 +31340,7 @@ function createSyncApi(ctx, deps) {
30739
31340
  clearFrames(state);
30740
31341
  destroyPools(state);
30741
31342
  clearLayers(state);
31343
+ forgetBuild(state);
30742
31344
  state.root = void 0;
30743
31345
  state.passesVersion += 1;
30744
31346
  };
@@ -30840,6 +31442,7 @@ function createSyncApi(ctx, deps) {
30840
31442
  stepFilters: () => applySwitched(state, deps.kept),
30841
31443
  drainOverlayEvents: (monitor) => drainOverlayEvents(sctx, monitor),
30842
31444
  warmStep: () => warmStep(sctx),
31445
+ takeBuildWork: () => takeBuildWork(state.aside),
30843
31446
  uploadStep: () => uploadStep(sctx),
30844
31447
  onWarm: (fn) => {
30845
31448
  state.warm.listeners.push(fn);
@@ -30868,7 +31471,7 @@ function createSyncApi(ctx, deps) {
30868
31471
  start: () => {
30869
31472
  if (deps.host.stage() === void 0) return;
30870
31473
  const ecs = ctx.deps.world.ecs;
30871
- 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)));
30872
31475
  rebuildAll();
30873
31476
  }
30874
31477
  };
@@ -31649,8 +32252,24 @@ function createUploadState() {
31649
32252
  };
31650
32253
  }
31651
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
+ /**
31652
32270
  * Creates the initial sync state: no root, no layers, no view, no orphan, no pool, no provider, no filter, no
31653
- * 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.
31654
32273
  *
31655
32274
  * @param debug - The debug switches of the config; copied, so the frozen config stays untouched.
31656
32275
  * @returns The sync branch of the plugin state.
@@ -31709,6 +32328,12 @@ function createSyncState(debug) {
31709
32328
  enabled: []
31710
32329
  },
31711
32330
  hudLayer: void 0,
32331
+ aside: createAsideQueue(),
32332
+ heldViews: {
32333
+ entities: [],
32334
+ count: 0,
32335
+ reported: false
32336
+ },
31712
32337
  cleanups: []
31713
32338
  };
31714
32339
  }
@@ -31816,7 +32441,7 @@ function checkAdaptiveScale(adaptiveScale) {
31816
32441
  * number from 64 to 16384, `keptMb` is not a finite number of at least 0, `warm` is not a
31817
32442
  * whole number from 0 to 16, or `uploads` is not a whole number from 0 to 8.
31818
32443
  */
31819
- function checkConfig(config) {
32444
+ function checkConfig$1(config) {
31820
32445
  const { maxResolution, keptMb } = config;
31821
32446
  const { budgetMs, frames } = config.monitor;
31822
32447
  if (!Number.isFinite(maxResolution) || maxResolution <= 0) throw new Error(`[game] renderer.maxResolution is ${maxResolution}.\n Give a number above 0.`);
@@ -31841,7 +32466,7 @@ function checkConfig(config) {
31841
32466
  * @throws {Error} When the config fails a check of `checkConfig`.
31842
32467
  */
31843
32468
  function createRendererState(ctx) {
31844
- checkConfig(ctx.config);
32469
+ checkConfig$1(ctx.config);
31845
32470
  return {
31846
32471
  host: createHostState(),
31847
32472
  viewport: createViewportState(),
@@ -35799,7 +36424,7 @@ function usedMb(state) {
35799
36424
  }
35800
36425
  /**
35801
36426
  * Picks the bundle that leaves next: the loaded one with the smallest use counter that is not
35802
- * 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
35803
36428
  * `release` is not read: a `"leave"` bundle is a candidate like any other.
35804
36429
  *
35805
36430
  * @param state - The plugin state.
@@ -35813,6 +36438,7 @@ function pickVictim(state) {
35813
36438
  if (record.status !== "loaded" || entry === void 0) continue;
35814
36439
  if (state.pinned.has(name) || isPermanent(entry.tier)) continue;
35815
36440
  if (state.sceneNamed.includes(name) || state.sceneLeaving.includes(name)) continue;
36441
+ if (state.sceneComing.includes(name)) continue;
35816
36442
  if (state.queue?.bundles.includes(name) === true) continue;
35817
36443
  if (record.lastUsed >= smallest) continue;
35818
36444
  smallest = record.lastUsed;
@@ -35968,9 +36594,9 @@ function featuresOnStack(ctx) {
35968
36594
  }
35969
36595
  /**
35970
36596
  * Tells whether a bundle must stay where the game stands now: it is pinned (a bundle of the node
35971
- * entered last), the mounted scene names it (the last list `sceneBundles` received), a leaving
35972
- * scene named it and its elements still play their exit, its feature is in `onStack`, or its tier
35973
- * 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.
35974
36600
  *
35975
36601
  * @param ctx - Domain context of the plugin.
35976
36602
  * @param name - Name of the bundle.
@@ -35978,9 +36604,9 @@ function featuresOnStack(ctx) {
35978
36604
  * @param onStack - The features on the stack, from `featuresOnStack`.
35979
36605
  * @returns True when the bundle is held.
35980
36606
  */
35981
- function isHeld(ctx, name, entry, onStack) {
36607
+ function isHeld$1(ctx, name, entry, onStack) {
35982
36608
  const state = ctx.state;
35983
- 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);
35984
36610
  }
35985
36611
  /**
35986
36612
  * Unloads every loaded `"leave"` bundle nobody holds any more, with the reason `"leave"`. A
@@ -35999,7 +36625,7 @@ function releaseLeftBundles(ctx) {
35999
36625
  /**
36000
36626
  * Takes the list the mounted scene names now. A bundle the list named before and names no more
36001
36627
  * stays held as a leaving scene's, until nothing carries `Exiting` (F51); a name the list takes
36002
- * 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.
36003
36629
  *
36004
36630
  * @param state - The plugin state.
36005
36631
  * @param named - The bundles the mounted scene names now.
@@ -36009,6 +36635,17 @@ function nameSceneBundles(state, named) {
36009
36635
  for (const name of state.sceneNamed) if (!named.includes(name) && !leaving.includes(name)) leaving.push(name);
36010
36636
  state.sceneLeaving = leaving;
36011
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];
36012
36649
  }
36013
36650
  /**
36014
36651
  * Lets the bundles a leaving scene named go once nothing carries `Exiting` any more: each loaded
@@ -36018,14 +36655,17 @@ function nameSceneBundles(state, named) {
36018
36655
  * its feature shows (the splash's 7.5 MiB under the ward). A bundle of another release stays, as
36019
36656
  * it would have. The system `assets.leave` calls it in every phase `sync`: after the reconcile of
36020
36657
  * `layout`, which tags the leaving elements `Exiting` in the frame after the switch, so the hold
36021
- * never ends before the leave begins (F51b). It costs a length read while no scene leaves, and a
36022
- * 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.
36023
36663
  *
36024
36664
  * @param ctx - Domain context of the plugin.
36025
36665
  */
36026
36666
  function releaseSceneLeft(ctx) {
36027
36667
  const state = ctx.state;
36028
- if (state.sceneLeaving.length === 0 || state.exiting > 0) return;
36668
+ if (state.sceneLeaving.length === 0 || state.exiting > 0 || state.sceneComing.length > 0) return;
36029
36669
  const names = state.sceneLeaving;
36030
36670
  state.sceneLeaving = [];
36031
36671
  if (state.io === void 0) return;
@@ -36045,7 +36685,7 @@ function releaseSceneLeft(ctx) {
36045
36685
  function unloadIfLeft(ctx, name, onStack) {
36046
36686
  const entry = ctx.state.manifest.bundles[name];
36047
36687
  if (ctx.state.records.get(name)?.status !== "loaded" || entry?.release !== "leave") return;
36048
- if (isHeld(ctx, name, entry, onStack)) return;
36688
+ if (isHeld$1(ctx, name, entry, onStack)) return;
36049
36689
  unloadBundle(ctx, name, "leave");
36050
36690
  }
36051
36691
  //#endregion
@@ -36438,7 +37078,7 @@ function affordable(ctx, distances) {
36438
37078
  for (const name of ordered) {
36439
37079
  const entry = ctx.state.manifest.bundles[name];
36440
37080
  if (entry === void 0 || entry.tier === "lazy") continue;
36441
- if (entry.release === "leave" && !isHeld(ctx, name, entry, onStack)) continue;
37081
+ if (entry.release === "leave" && !isHeld$1(ctx, name, entry, onStack)) continue;
36442
37082
  if (!isStillWanted(ctx.state, name)) continue;
36443
37083
  if (running + entry.mb > ctx.config.textureBudgetMb) break;
36444
37084
  running += entry.mb;
@@ -36874,6 +37514,7 @@ function releaseAll(state) {
36874
37514
  state.sceneId = void 0;
36875
37515
  state.sceneNamed = [];
36876
37516
  state.sceneLeaving = [];
37517
+ state.sceneComing = [];
36877
37518
  state.exiting = 0;
36878
37519
  state.restPending = false;
36879
37520
  state.stamps = void 0;
@@ -37023,6 +37664,7 @@ function createAssetsApi(ctx) {
37023
37664
  audio: (key) => loadedRecord(assets, key)?.audio.get(key),
37024
37665
  usage: () => usage(assets),
37025
37666
  sceneBundles: (named) => nameSceneBundles(ctx.state, named),
37667
+ sceneComing: (named) => nameComingBundles(ctx.state, named),
37026
37668
  has: (bundle) => Object.hasOwn(ctx.state.manifest.bundles, bundle),
37027
37669
  companions: { add: (companion) => addCompanion(ctx.state, companion) }
37028
37670
  };
@@ -37691,6 +38333,7 @@ function createAssetsState() {
37691
38333
  swapping: void 0,
37692
38334
  sceneNamed: [],
37693
38335
  sceneLeaving: [],
38336
+ sceneComing: [],
37694
38337
  exiting: 0,
37695
38338
  companions: [],
37696
38339
  restPending: false,
@@ -37759,161 +38402,7 @@ function createScenesApi(ctx) {
37759
38402
  };
37760
38403
  }
37761
38404
  //#endregion
37762
- //#region src/plugins/scenes/switch.ts
37763
- /**
37764
- * A promise that resolves when the node was aborted. Already aborted resolves at once.
37765
- *
37766
- * @param signal - The abort signal of the node being entered.
37767
- * @returns A promise that resolves on abort and never rejects.
37768
- */
37769
- function whenAborted$1(signal) {
37770
- return new Promise((resolve) => {
37771
- if (signal.aborted) {
37772
- resolve();
37773
- return;
37774
- }
37775
- signal.addEventListener("abort", () => resolve(), { once: true });
37776
- });
37777
- }
37778
- /**
37779
- * Waits for the textures of loaded bundles to reach the GPU, or for the abort. It never rejects:
37780
- * a warm that fails (an unload during it) is logged and the switch goes on, its first draw
37781
- * uploads as before. Headless, without a device or with `uploads: 0` the warms answer at once,
37782
- * and textures already on the GPU are not waited for.
37783
- *
37784
- * @param ctx - Domain context of the plugin.
37785
- * @param bundles - The bundles the scene being entered names, all of them loaded.
37786
- * @param signal - The abort signal of the node.
37787
- * @returns A promise that resolves when every warm settled or the node was aborted.
37788
- */
37789
- async function untilWarmed(ctx, bundles, signal) {
37790
- const warms = bundles.map((bundle) => ctx.deps.assets.warm(bundle).catch((error) => {
37791
- ctx.log.warn("scenes: a bundle's textures were not uploaded before the switch", {
37792
- bundle,
37793
- error
37794
- });
37795
- }));
37796
- await Promise.race([Promise.all(warms), whenAborted$1(signal)]);
37797
- }
37798
- /**
37799
- * Loads every bundle the scene names at once and returns as soon as all loads settled or the node
37800
- * is aborted. The first load that fails while the node still runs is rethrown, so `flow` rolls
37801
- * the node back; one that fails after the abort is logged, because nobody waits for it any more.
37802
- *
37803
- * @param ctx - Domain context of the plugin.
37804
- * @param bundles - The bundles the scene being entered names, its own bundle first.
37805
- * @param signal - The abort signal of the node.
37806
- * @returns A promise that resolves when the loads settled or the node was aborted.
37807
- * @throws {Error} Whatever the first failed load rejected with, while the node was not aborted.
37808
- */
37809
- async function untilAborted(ctx, bundles, signal) {
37810
- const load = {
37811
- failed: false,
37812
- error: void 0
37813
- };
37814
- const loads = bundles.map((bundle) => ctx.deps.assets.load(bundle).catch((error) => {
37815
- if (!load.failed) {
37816
- load.failed = true;
37817
- load.error = error;
37818
- }
37819
- if (signal.aborted) ctx.log.warn("scenes: a cancelled bundle load failed", {
37820
- bundle,
37821
- error
37822
- });
37823
- }));
37824
- await Promise.race([Promise.all(loads), whenAborted$1(signal)]);
37825
- if (load.failed && !signal.aborted) throw load.error;
37826
- }
37827
- /**
37828
- * Reads the committed state a scene's `bundles` gets: the frozen trees of the store, in a frozen
37829
- * wrapper. During the scene stage no node writes, so it is the state the node is entered with.
37830
- *
37831
- * @param ctx - Domain context of the plugin.
37832
- * @returns The committed `player` and `session`.
37833
- */
37834
- function committedState(ctx) {
37835
- const { player, session } = ctx.deps.model.store.snapshot();
37836
- return Object.freeze({
37837
- player,
37838
- session
37839
- });
37840
- }
37841
- /**
37842
- * Tells whether two lists name the same bundles in the same order.
37843
- *
37844
- * @param left - One list.
37845
- * @param right - The other list.
37846
- * @returns True when both have the same names at the same places.
37847
- * @example
37848
- * ```ts
37849
- * sameNames(["location", "ep01"], ["location", "ep02"]); // false
37850
- * ```
37851
- */
37852
- function sameNames(left, right) {
37853
- return left.length === right.length && left.every((name, index) => name === right[index]);
37854
- }
37855
- /**
37856
- * Takes the expected scene and clears the record, so one `expect` mounts one scene.
37857
- *
37858
- * @param ctx - Domain context of the plugin.
37859
- * @returns The scene id that was recorded or expected, or `undefined` when there was none.
37860
- */
37861
- function takePending(ctx) {
37862
- const target = ctx.state.pending;
37863
- ctx.state.pending = void 0;
37864
- return target;
37865
- }
37866
- /**
37867
- * Tells whether the node is a transit node of a fast walk: the walk passes through it and builds
37868
- * no scene there.
37869
- *
37870
- * @param node - The node being entered.
37871
- * @param run - The runner's mode and signal.
37872
- * @returns True for a node without `rest` entered in fast mode.
37873
- */
37874
- function isFastTransit(node, run) {
37875
- return run.mode === "fast" && !node.rest;
37876
- }
37877
- /**
37878
- * Reads the scene the node asks for. In fast mode a transit node only records it: the walk passes
37879
- * through many of them and the picture is built once, at the rest point. In live mode a transit
37880
- * node without a scene of its own takes the expected one, so a restore at a popup that waits for
37881
- * its answer builds the scene under it; one with its own scene leaves the record alone.
37882
- *
37883
- * @param ctx - Domain context of the plugin.
37884
- * @param node - The node being entered.
37885
- * @param run - The runner's mode and signal.
37886
- * @returns The scene id to switch to, or `undefined` when nothing has to happen.
37887
- */
37888
- function targetOf(ctx, node, run) {
37889
- const state = ctx.state;
37890
- if (isFastTransit(node, run)) {
37891
- state.pending = node.scene ?? state.pending;
37892
- return;
37893
- }
37894
- if (!node.rest) return node.scene ?? takePending(ctx);
37895
- const target = node.scene ?? state.pending;
37896
- state.pending = void 0;
37897
- if (target === void 0 && state.current === void 0) ctx.log.warn("scenes: a rest node was entered with no scene", { path: node.path });
37898
- return target;
37899
- }
37900
- /**
37901
- * Reads the scene an `over` node asks for. An over node takes the expected scene and clears the
37902
- * record, whatever is mounted, so a restore at a popup builds the scene under it, whether the
37903
- * popup rests or waits for its answer. With nothing expected it switches nothing. In fast mode an
37904
- * over transit node takes nothing and leaves the record alone: a walk builds no scene on the way.
37905
- * A scene the over node names itself is only warned about.
37906
- *
37907
- * @param ctx - Domain context of the plugin.
37908
- * @param node - The over node being entered.
37909
- * @param run - The runner's mode and signal.
37910
- * @returns The expected scene id, or `undefined` when nothing has to happen.
37911
- */
37912
- function overTargetOf(ctx, node, run) {
37913
- if (node.scene !== void 0) ctx.log.warn("scenes: an over node cannot name a scene", { path: node.path });
37914
- if (isFastTransit(node, run)) return void 0;
37915
- return takePending(ctx);
37916
- }
38405
+ //#region src/plugins/scenes/swap.ts
37917
38406
  /**
37918
38407
  * The projections of the scene that leaves which the scene that comes does not list. A projection
37919
38408
  * both list stays mounted: the layers of a projection are the ones its spec names, and both scenes
@@ -37927,23 +38416,55 @@ function leavingOf(previous, scene) {
37927
38416
  return previous.projections.filter((name) => !scene.projections.includes(name));
37928
38417
  }
37929
38418
  /**
37930
- * Builds the scene in one synchronous block: the layers, then the projections only the old scene
37931
- * lists out, then the list of the new scene in (a projection both list stays and takes its new
37932
- * place), the new scene to the world's systems, then the list the scene names to `assets`, then
37933
- * 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.
37934
38452
  *
37935
38453
  * @param ctx - Domain context of the plugin.
37936
38454
  * @param scene - The scene whose bundles are there.
37937
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.
37938
38458
  */
37939
- function apply(ctx, scene, named) {
38459
+ function swapIn(ctx, scene, named, build) {
37940
38460
  const state = ctx.state;
37941
38461
  const projection = ctx.deps.world.projection;
37942
- const from = state.current;
38462
+ const from = build === void 0 ? state.current : build.from;
37943
38463
  const previous = from === void 0 ? void 0 : state.scenes.get(from);
37944
38464
  projection.setLayers(scene.layers);
37945
38465
  if (previous !== void 0) projection.unmount(leavingOf(previous, scene));
37946
- projection.mount(scene.projections, state.owner);
38466
+ if (build === void 0) projection.mount(scene.projections, state.owner);
38467
+ else projection.commit();
37947
38468
  state.current = scene.id;
37948
38469
  ctx.deps.world.ecs.setScene(scene.id);
37949
38470
  state.named = named;
@@ -37955,16 +38476,424 @@ function apply(ctx, scene, named) {
37955
38476
  to: scene.id,
37956
38477
  music: scene.music
37957
38478
  });
37958
- if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) ctx.deps.renderer.monitor.event("scene", {
37959
- from: from ?? null,
37960
- 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 });
37961
38709
  });
37962
38710
  }
37963
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
38772
+ });
38773
+ }
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
+ /**
37964
38892
  * Switches to another scene: it reads what the scene names from the committed state, awaits all
37965
38893
  * of it and only then builds the scene. In live mode it waits between the two for the textures of
37966
38894
  * those bundles to reach the GPU; a fast walk does not, so tests and restores keep their pace. An
37967
- * 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.
37968
38897
  *
37969
38898
  * @param ctx - Domain context of the plugin.
37970
38899
  * @param target - Id of the scene to mount.
@@ -37980,7 +38909,7 @@ async function switchTo(ctx, target, run) {
37980
38909
  if (run.mode === "live" && !run.signal.aborted) await untilWarmed(ctx, named, run.signal);
37981
38910
  const stopped = ctx.state.teardown === void 0;
37982
38911
  if (run.signal.aborted || stopped) return;
37983
- apply(ctx, scene, named);
38912
+ await standUp(ctx, scene, named, run);
37984
38913
  }
37985
38914
  /**
37986
38915
  * Reads again what the mounted scene names, at a node that keeps it: a bundle it names now and
@@ -38016,8 +38945,8 @@ async function reread(ctx, run) {
38016
38945
  * @param run - The runner's mode and signal.
38017
38946
  * @returns A promise that resolves once the scene stands, or at once when nothing changed.
38018
38947
  * @throws {Error} For a scene id no feature declared, and for a `bundles` that throws or returns
38019
- * something that is not a list of names. A failed load and a failed `mount` also reject, so
38020
- * `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.
38021
38950
  */
38022
38951
  async function enterScene(ctx, node, run) {
38023
38952
  const target = node.over ? overTargetOf(ctx, node, run) : targetOf(ctx, node, run);
@@ -38031,8 +38960,9 @@ async function enterScene(ctx, node, run) {
38031
38960
  //#endregion
38032
38961
  //#region src/plugins/scenes/lifecycle.ts
38033
38962
  /**
38034
- * @file scenes plugin — lifecycle: the scenes read out of the feature descriptions in `onStart`,
38035
- * 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.
38036
38966
  */
38037
38967
  /**
38038
38968
  * Resolves the dependency APIs `flow`, `world`, `assets`, `time`, `model` and `renderer` with
@@ -38093,6 +39023,20 @@ function readScenes(ctx) {
38093
39023
  }
38094
39024
  }
38095
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
+ /**
38096
39040
  * Starts the plugin in `onStart`: it collects the scenes of the features and registers the one
38097
39041
  * callback that switches them, on the `scene` stage of entering a node.
38098
39042
  *
@@ -38111,8 +39055,9 @@ function startScenes(ctx) {
38111
39055
  }
38112
39056
  /**
38113
39057
  * Removes the `onEnter` callback in `onStop` and forgets every scene and the bundles the mounted
38114
- * one named. Nothing is unmounted: a
38115
- * 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.
38116
39061
  *
38117
39062
  * @param teardown - What the teardown context carries.
38118
39063
  * @param teardown.state - The plugin state.
@@ -38126,6 +39071,8 @@ function stopScenes(teardown) {
38126
39071
  const state = teardown.state;
38127
39072
  state.teardown?.();
38128
39073
  state.teardown = void 0;
39074
+ dropBuild(state.build);
39075
+ delete state.build;
38129
39076
  state.scenes.clear();
38130
39077
  state.current = void 0;
38131
39078
  state.pending = void 0;
@@ -38170,8 +39117,6 @@ function createScenesState() {
38170
39117
  teardown: void 0
38171
39118
  };
38172
39119
  }
38173
- //#endregion
38174
- //#region src/plugins/scenes/index.ts
38175
39120
  /**
38176
39121
  * Scenes plugin: `app.scenes.current()`, and `defineScene` for the game.
38177
39122
  *
@@ -38193,6 +39138,9 @@ function createScenesState() {
38193
39138
  * layers: { room: {} },
38194
39139
  * projections: [wardRoom]
38195
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 } } });
38196
39144
  * ```
38197
39145
  */
38198
39146
  const scenesPlugin = /*#__PURE__*/ createPlugin$1("scenes", {
@@ -38204,9 +39152,11 @@ const scenesPlugin = /*#__PURE__*/ createPlugin$1("scenes", {
38204
39152
  modelPlugin,
38205
39153
  rendererPlugin
38206
39154
  ],
39155
+ config: { buildMs: "auto" },
38207
39156
  events: (register) => register.map({ "scenes:changed": "The mounted scene changed" }),
38208
39157
  createState: createScenesState,
38209
39158
  api: createScenesApi,
39159
+ onInit: ({ config }) => checkConfig(config),
38210
39160
  onStart: startScenes,
38211
39161
  onStop: ({ state }) => stopScenes({ state })
38212
39162
  });
@@ -52473,6 +53423,9 @@ function ownerOf(state, name) {
52473
53423
  /**
52474
53424
  * Gives the host as parent to every view of one projection that has no parent and is not held
52475
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.
52476
53429
  *
52477
53430
  * @param ctx - Domain context of the ui plugin.
52478
53431
  * @param state - The jsx state.
@@ -52481,8 +53434,10 @@ function ownerOf(state, name) {
52481
53434
  */
52482
53435
  function parentViews(ctx, state, name, host) {
52483
53436
  const ecs = ctx.deps.world.ecs;
52484
- for (const view of ctx.deps.world.projection.entitiesOf(name)) {
52485
- 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;
52486
53441
  if (ecs.has(view, Parent) && !state.released.has(view)) continue;
52487
53442
  ecs.add(view, Parent({ entity: host }));
52488
53443
  state.hosted.set(view, host);
@@ -52604,17 +53559,21 @@ function keptSlot(state, root) {
52604
53559
  }
52605
53560
  /**
52606
53561
  * Tells whether a host element may take a root as its slot: it names the root's projection, it is
52607
- * 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.
52608
53565
  *
52609
53566
  * @param state - The jsx state.
52610
53567
  * @param root - The root.
52611
53568
  * @param host - An entity of the host set.
53569
+ * @param isAside - Whether an entity belongs to the scene `world` builds aside.
52612
53570
  * @returns The host element, or `undefined` when it cannot take the root.
52613
53571
  */
52614
- function slotFor(state, root, host) {
53572
+ function slotFor(state, root, host, isAside) {
52615
53573
  const element = state.elements.get(host);
52616
53574
  if (element === void 0 || state.exiting.has(host) || element.root === root.entity) return;
52617
- 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;
52618
53577
  }
52619
53578
  /**
52620
53579
  * The host element a root is laid out in (spec 16 U3.2). A popup root never is. A root keeps its
@@ -52623,13 +53582,14 @@ function slotFor(state, root, host) {
52623
53582
  *
52624
53583
  * @param state - The jsx state.
52625
53584
  * @param root - The root.
53585
+ * @param isAside - Whether an entity belongs to the scene `world` builds aside.
52626
53586
  * @returns The slot, or `undefined` for a root laid out on the viewport.
52627
53587
  */
52628
- function slotOf$1(state, root) {
53588
+ function slotOf$1(state, root, isAside) {
52629
53589
  if (root.popup !== void 0) return void 0;
52630
53590
  const kept = keptSlot(state, root);
52631
53591
  if (kept !== void 0) return kept;
52632
- 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;
52633
53593
  }
52634
53594
  /**
52635
53595
  * Where an element is drawn at rest, in root coordinates: its drawn rect (`visualRectOf`), and for
@@ -53059,7 +54019,8 @@ function createFields(ctx, links) {
53059
54019
  }
53060
54020
  /**
53061
54021
  * Spawns the four parts of a field, each a child of the field: its pose, its `Parent`, its
53062
- * 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).
53063
54024
  *
53064
54025
  * @param entity - The field entity.
53065
54026
  * @param values - What the parts draw.
@@ -53067,15 +54028,22 @@ function createFields(ctx, links) {
53067
54028
  */
53068
54029
  function spawnParts(entity, values) {
53069
54030
  const ecs = ctx.deps.world.ecs;
54031
+ const projection = ctx.deps.world.projection;
53070
54032
  const parent = Parent({ entity });
53071
- 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([
53072
54040
  Transform(part.pose),
53073
54041
  parent,
53074
54042
  Shape(part.shape)
53075
54043
  ]);
53076
54044
  return {
53077
54045
  selection: box(values.selection),
53078
- text: ecs.spawn(UI_OWNER, [
54046
+ text: spawn([
53079
54047
  Transform(values.text.pose),
53080
54048
  parent,
53081
54049
  Text({
@@ -53806,18 +54774,20 @@ function nodeOf(state, element, resolvedOf, place) {
53806
54774
  /**
53807
54775
  * Every mounted root in the order a reader meets them: the popup roots first, the highest `Order`
53808
54776
  * of them first, then the projection roots in the layer order of the scene, and by their `Order`
53809
- * 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).
53810
54779
  *
53811
54780
  * @param state - The jsx state.
53812
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.
53813
54783
  * @returns The roots, sorted.
53814
54784
  * @example
53815
54785
  * ```ts
53816
54786
  * sortedRoots({ roots: new Map() } as unknown as JsxState, ["ui"]); // []
53817
54787
  * ```
53818
54788
  */
53819
- function sortedRoots(state, layers) {
53820
- 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));
53821
54791
  const popups = roots.filter((root) => root.popup !== void 0);
53822
54792
  const screens = roots.filter((root) => root.popup === void 0);
53823
54793
  const indexOf = (root) => {
@@ -53842,22 +54812,24 @@ function placeOf(root, slotPose) {
53842
54812
  return (rect) => drawnRect(pose, rect);
53843
54813
  }
53844
54814
  /**
53845
- * Reads every mounted root, popups first, then the projection roots in layer order. The rects of a
53846
- * 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.
53847
54818
  *
53848
54819
  * @param state - The jsx state.
53849
54820
  * @param layers - The layer names of the scene, in draw order.
53850
54821
  * @param resolvedOf - Reads the string `text` resolved for an entity; none in a read without a world.
53851
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.
53852
54824
  * @returns One root node, or every root under a `screen` node.
53853
54825
  * @example
53854
54826
  * ```ts
53855
54827
  * readTree({ roots: new Map(), elements: new Map() } as unknown as JsxState).type; // "screen"
53856
54828
  * ```
53857
54829
  */
53858
- function readTree(state, layers = [], resolvedOf, slotPose) {
54830
+ function readTree(state, layers = [], resolvedOf, slotPose, isAside) {
53859
54831
  const nodes = [];
53860
- for (const root of sortedRoots(state, layers)) {
54832
+ for (const root of sortedRoots(state, layers, isAside)) {
53861
54833
  const element = root.element === void 0 ? void 0 : state.elements.get(root.element);
53862
54834
  if (element !== void 0) nodes.push(nodeOf(state, element, resolvedOf, placeOf(root, slotPose)));
53863
54835
  }
@@ -53900,15 +54872,17 @@ function readingOrder(first, second) {
53900
54872
  /**
53901
54873
  * The root the keyboard works in: the first uncovered popup in reader order, else the last screen
53902
54874
  * root, the one in the top layer. A root of the HUD layer never takes it: the HUD draws above every
53903
- * 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.
53904
54877
  *
53905
54878
  * @param state - The jsx state.
53906
54879
  * @param layers - The layer names of the scene, in draw order.
53907
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.
53908
54882
  * @returns The root, or `undefined` when nothing is mounted.
53909
54883
  */
53910
- function focusRoot(state, layers, hud) {
53911
- 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));
53912
54886
  return roots.find((root) => root.popup !== void 0 && !root.covered) ?? roots.findLast((root) => root.popup === void 0 && root.layer !== hud);
53913
54887
  }
53914
54888
  /**
@@ -54368,7 +55342,8 @@ function zIndexOnRoot(element) {
54368
55342
  };
54369
55343
  }
54370
55344
  /**
54371
- * 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).
54372
55347
  *
54373
55348
  * @param ctx - Domain context of the ui plugin.
54374
55349
  * @returns One finding per rule and element, in element order.
@@ -54377,7 +55352,7 @@ function runLint(ctx) {
54377
55352
  const findings = [];
54378
55353
  const scale = ctx.deps.renderer.viewport.size().scale;
54379
55354
  for (const element of ctx.state.jsx.elements.values()) {
54380
- if (!element.live) continue;
55355
+ if (!element.live || ctx.deps.world.projection.isBuilding(element.root)) continue;
54381
55356
  for (const finding of [
54382
55357
  tapTarget(ctx, element, scale),
54383
55358
  textOverflow(ctx, element),
@@ -56542,6 +57517,8 @@ function createReconciler(ctx, modules, fields) {
56542
57517
  let slotsRoots = -1;
56543
57518
  let placedUnder;
56544
57519
  let rendered = 0;
57520
+ const isAside = (entity) => ctx.deps.world.projection.isBuilding(entity);
57521
+ let released = false;
56545
57522
  /**
56546
57523
  * Registers the key of an element under its root, so `entityOf` and `find` answer for it. A key
56547
57524
  * `ui` gave a node of its own, a spacer's, is never registered.
@@ -56570,7 +57547,9 @@ function createReconciler(ctx, modules, fields) {
56570
57547
  return range.rowStyle(parent, node, modules.styles.resolveElement(styleOf(node), is));
56571
57548
  }
56572
57549
  /**
56573
- * 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).
56574
57553
  *
56575
57554
  * @param root - The root being reconciled.
56576
57555
  * @param parent - The parent element, or nothing for a root element.
@@ -56583,6 +57562,7 @@ function createReconciler(ctx, modules, fields) {
56583
57562
  function enter(root, parent, node, identity, instance, index) {
56584
57563
  checkTag(node);
56585
57564
  const entity = ecs.spawn(UI_OWNER, []);
57565
+ if (isAside(root.entity)) ctx.deps.world.projection.adopt(entity);
56586
57566
  const is = isFlagsOf(node, {
56587
57567
  pressed: false,
56588
57568
  hover: false,
@@ -57032,13 +58012,18 @@ function createReconciler(ctx, modules, fields) {
57032
58012
  root.needsSolve = true;
57033
58013
  }
57034
58014
  /**
57035
- * 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).
57036
58017
  *
57037
58018
  * @param element - The element that left the description.
57038
58019
  * @param cause - Why it leaves: `"change"` for a commit, `"mount"` when it goes with its screen.
57039
58020
  * @param leads - The element is the top of a root that goes with its screen.
57040
58021
  */
57041
58022
  function exitElement(element, cause, leads = false) {
58023
+ if (isAside(element.root)) {
58024
+ dropElement(element);
58025
+ return;
58026
+ }
57042
58027
  forgetSubtree(element);
57043
58028
  forgetInstances(state, element.identity);
57044
58029
  state.exiting.add(element.entity);
@@ -57128,6 +58113,17 @@ function createReconciler(ctx, modules, fields) {
57128
58113
  ecs.despawn(element.entity);
57129
58114
  }
57130
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
+ /**
57131
58127
  * Despawns every exiting element whose motions finished, and closes a popup whose root is gone.
57132
58128
  */
57133
58129
  function sweep() {
@@ -57291,7 +58287,7 @@ function createReconciler(ctx, modules, fields) {
57291
58287
  */
57292
58288
  function warnTaken(root, slot) {
57293
58289
  for (const host of state.hosts) {
57294
- const element = host === slot ? void 0 : slotFor(state, root, host);
58290
+ const element = host === slot ? void 0 : slotFor(state, root, host, isAside);
57295
58291
  const once = `slot-taken:${root.name}`;
57296
58292
  if (element === void 0 || element.warned.has(once)) continue;
57297
58293
  element.warned.add(once);
@@ -57323,7 +58319,7 @@ function createReconciler(ctx, modules, fields) {
57323
58319
  slotsRoots = state.roots.size;
57324
58320
  for (const root of state.roots.values()) {
57325
58321
  if (state.removing.has(root.entity)) continue;
57326
- const slot = isCyclic(root) ? void 0 : slotOf$1(state, root);
58322
+ const slot = isCyclic(root) ? void 0 : slotOf$1(state, root, isAside);
57327
58323
  if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ && slot !== void 0) warnTaken(root, slot);
57328
58324
  if (slot === root.slot) continue;
57329
58325
  root.slot = slot;
@@ -57458,7 +58454,9 @@ function createReconciler(ctx, modules, fields) {
57458
58454
  * Keeps an element whose spawn has just been applied: the world calls it from the `Box` hook, at
57459
58455
  * the flush of phase `layout`. Its enter hook waits for `playEntered`, so the game's systems see
57460
58456
  * the entity first (F48). The hook itself plays at once, so they see the start pose it writes (F49);
57461
- * `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).
57462
58460
  *
57463
58461
  * @param entity - The entity the `Box` was attached to.
57464
58462
  */
@@ -57468,21 +58466,45 @@ function createReconciler(ctx, modules, fields) {
57468
58466
  else entering.list.push(entity);
57469
58467
  entering.count += 1;
57470
58468
  const element = state.elements.get(entity);
57471
- 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
+ }
57472
58486
  }
57473
58487
  /**
57474
58488
  * Plays the enter hooks queued since the last call, in the order the elements came. The `ui.enter`
57475
58489
  * system calls it in phase `sync`: after the game's systems, which may have written what a hook
57476
58490
  * reads (a list's scroll), and before `renderer.sync`, so an element is never drawn at its rest
57477
- * 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.
57478
58495
  */
57479
58496
  function playEntered() {
57480
58497
  const entering = state.entering;
58498
+ let held = 0;
57481
58499
  for (let index = 0; index < entering.count; index += 1) {
57482
58500
  const entity = entering.list[index];
57483
- 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);
57484
58506
  }
57485
- entering.count = 0;
58507
+ entering.count = held;
57486
58508
  }
57487
58509
  /**
57488
58510
  * Plays the enter hook of an element whose spawn has been applied, once. A row a range change
@@ -57623,13 +58645,22 @@ function createReconciler(ctx, modules, fields) {
57623
58645
  /**
57624
58646
  * Reconciles one dirty root: reads its current tree and projection name, then diffs it. The root
57625
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).
57626
58650
  *
57627
58651
  * @param root - A root with work this frame.
57628
58652
  */
57629
58653
  function reconcileDirtyRoot(root) {
58654
+ if (root.aside === void 0 && isAside(root.entity)) {
58655
+ root.aside = true;
58656
+ state.asideRoots += 1;
58657
+ }
57630
58658
  const tree = ecs.get(root.entity, Tree);
57631
58659
  const place = ctx.deps.world.projection.keyOf(root.entity);
57632
- if (place !== void 0) root.name = place.projection;
58660
+ if (place !== void 0 && place.projection !== root.name) {
58661
+ root.name = place.projection;
58662
+ hostsMoved(state);
58663
+ }
57633
58664
  if (tree !== void 0) root.tree = tree.node;
57634
58665
  refreshRootOrder(root);
57635
58666
  root.dirty = !modules.layout.loaded();
@@ -57641,16 +58672,55 @@ function createReconciler(ctx, modules, fields) {
57641
58672
  }
57642
58673
  }
57643
58674
  /**
57644
- * The first of the two systems of phase `layout`: the sweep, the roots a restore asked to build
57645
- * again, the roots a scene placed again, the popups that may leave, the pull of the text being
57646
- * typed, the scroll offsets and the windowed ranges, the bound texts that show a string of
57647
- * 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.
57648
58717
  *
57649
58718
  * @param time - The frame's `Time`: the scrolls move by its `delta`.
57650
58719
  */
57651
58720
  function reconcile(time = NO_TIME) {
57652
58721
  sweep();
57653
58722
  state.reconciles += 1;
58723
+ settleAside();
57654
58724
  if (state.remount) remountRoots();
57655
58725
  reorderRoots();
57656
58726
  settlePopups(ctx, state, unmountRoot);
@@ -57690,7 +58760,8 @@ function createReconciler(ctx, modules, fields) {
57690
58760
  * The second system of phase `layout`: every root takes its slot, then one solve per marked
57691
58761
  * root, a root in a slot after the root of its slot and in its content box, then the rects, the
57692
58762
  * rest poses and the hooks, the scrolls settled on what the solve and the render gave them, the
57693
- * 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
57694
58765
  * at its end, so the `change.Box` and `change.Transform` hooks the solve plays get the hint the
57695
58766
  * reconcile routed, and the next frame starts with none.
57696
58767
  *
@@ -57711,6 +58782,10 @@ function createReconciler(ctx, modules, fields) {
57711
58782
  state.risen.clear();
57712
58783
  }
57713
58784
  if (state.exiting.size > 0 || state.removing.size > 0) sweep();
58785
+ if (released) {
58786
+ released = false;
58787
+ poseReleased();
58788
+ }
57714
58789
  state.hints.length = 0;
57715
58790
  writeCounters();
57716
58791
  }
@@ -57764,7 +58839,9 @@ function createReconciler(ctx, modules, fields) {
57764
58839
  * nodes and despawns the root entity. A popup leaves by an event; a projection root leaves the
57765
58840
  * way its view did, which the world still answers in its removal hook: `"mount"` with its scene,
57766
58841
  * a load or a restore. A root that goes with its screen ends with the exit of its top element
57767
- * 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).
57768
58845
  *
57769
58846
  * @param entity - The root entity.
57770
58847
  */
@@ -57773,6 +58850,10 @@ function createReconciler(ctx, modules, fields) {
57773
58850
  if (root === void 0 || state.removing.has(entity)) return;
57774
58851
  const element = root.element === void 0 ? void 0 : lookup(root.element);
57775
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
+ }
57776
58857
  state.removing.add(entity);
57777
58858
  hostsMoved(state);
57778
58859
  if (element !== void 0) {
@@ -57782,6 +58863,20 @@ function createReconciler(ctx, modules, fields) {
57782
58863
  ctx.deps.time.wake();
57783
58864
  }
57784
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
+ /**
57785
58880
  * Re-resolves the style of one element whose pointer or focus flag changed: a press, a release,
57786
58881
  * the mouse coming over it or leaving it, the keyboard focus arriving or leaving. A new rect
57787
58882
  * waits for the solve; a new look and a new rest pose apply now. Only this element is restyled:
@@ -57818,7 +58913,8 @@ function createReconciler(ctx, modules, fields) {
57818
58913
  queueEnter,
57819
58914
  playEntered,
57820
58915
  sweep,
57821
- converged
58916
+ converged,
58917
+ asideBuilt
57822
58918
  };
57823
58919
  }
57824
58920
  //#endregion
@@ -57894,9 +58990,10 @@ function createJsxApi(ctx, modules) {
57894
58990
  const state = ctx.state.jsx;
57895
58991
  const sceneLayers = sceneLayersOf(ctx);
57896
58992
  const layerNames = () => sceneLayers().names;
58993
+ const isAside = (entity) => ctx.deps.world.projection.isBuilding(entity);
57897
58994
  const topRoot = () => {
57898
58995
  const scene = sceneLayers();
57899
- return focusRoot(state, scene.names, scene.hud);
58996
+ return focusRoot(state, scene.names, scene.hud, isAside);
57900
58997
  };
57901
58998
  const fields = createFields(ctx, {
57902
58999
  layout: modules.layout,
@@ -57907,13 +59004,13 @@ function createJsxApi(ctx, modules) {
57907
59004
  const resolvedOf = (entity) => ctx.deps.world.ecs.get(entity, Text)?.resolved;
57908
59005
  const lookup = (entity) => state.elements.get(entity);
57909
59006
  const find = (key) => {
57910
- for (const root of sortedRoots(state, layerNames())) {
59007
+ for (const root of sortedRoots(state, layerNames(), isAside)) {
57911
59008
  const entity = state.byKey.get(root.entity)?.get(key);
57912
59009
  if (entity !== void 0 && !state.exiting.has(entity)) return entity;
57913
59010
  }
57914
59011
  };
57915
59012
  return {
57916
- 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),
57917
59014
  find,
57918
59015
  lint: () => runLint(ctx),
57919
59016
  fill: (key, value) => {
@@ -57974,6 +59071,7 @@ function createJsxApi(ctx, modules) {
57974
59071
  queueEnter: frame.queueEnter,
57975
59072
  playEntered: frame.playEntered,
57976
59073
  converged: frame.converged,
59074
+ asideBuilt: frame.asideBuilt,
57977
59075
  key: (input) => focus.key(input),
57978
59076
  blur: focus.blur
57979
59077
  };
@@ -58262,6 +59360,18 @@ function writeRest(ctx, element, parent) {
58262
59360
  if (visual !== void 0) projection.setRest(element.entity, visual.component, visual.value);
58263
59361
  }
58264
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
+ /**
58265
59375
  * Asks the projection for the handle of a ui-owned entity. A foreign owner is an error in the
58266
59376
  * log and the caller writes the value directly.
58267
59377
  *
@@ -58569,6 +59679,7 @@ function noteEndless(element, motion) {
58569
59679
  * Plays the `change` hook of one extra component whose fields changed. The motion the last change
58570
59680
  * of that name started is cancelled first, so its value stays where it is and the new motion
58571
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.
58572
59683
  *
58573
59684
  * @param ctx - Domain context of the ui plugin.
58574
59685
  * @param element - The element that carries the extra.
@@ -58587,7 +59698,7 @@ function playExtraChange(ctx, element, name, values, hint) {
58587
59698
  element.extraHandles.delete(name);
58588
59699
  }
58589
59700
  const hook = element.motion?.change?.[name];
58590
- if (hook === void 0) return false;
59701
+ if (hook === void 0 || isHeld(ctx, element)) return false;
58591
59702
  const motion = play(ctx, element, (view) => hook(view, values.previous, values.next, hint));
58592
59703
  if (motion === void 0) return false;
58593
59704
  element.extraHandles.set(name, motion);
@@ -58638,8 +59749,9 @@ function still(element) {
58638
59749
  * 0 ms rest track. That track writes the pose as it starts, so the pivot and the position land in
58639
59750
  * the same frame, and ends at the next frame step, where an additive loop re-bases on the new
58640
59751
  * rest. An element that has not spawned yet only records it, and so does one whose
58641
- * `change.Box` motion already played. The content of a scroll keeps its `Transform` and records
58642
- * 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.
58643
59755
  *
58644
59756
  * @param ctx - Domain context of the ui plugin.
58645
59757
  * @param element - The element whose rect, style or fit may have changed.
@@ -58655,6 +59767,13 @@ function repose(ctx, element, parent, hooked, hint) {
58655
59767
  if (element.type === "content") return;
58656
59768
  ctx.deps.world.projection.setRest(element.entity, Transform, next);
58657
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
+ }
58658
59777
  const hook = element.motion?.change?.Transform;
58659
59778
  if (hook !== void 0) {
58660
59779
  play(ctx, element, (view) => hook(view, previous, next, hint));
@@ -60195,7 +61314,7 @@ function createLayoutApi(ctx) {
60195
61314
  loop: (element) => startLoop(ctx, element),
60196
61315
  change: (element, previous, hint) => {
60197
61316
  const hook = element.motion?.change?.Box;
60198
- if (hook === void 0) return false;
61317
+ if (hook === void 0 || isHeld(ctx, element)) return false;
60199
61318
  play(ctx, element, (view) => hook(view, previous, element.rect, hint));
60200
61319
  return true;
60201
61320
  },
@@ -60771,8 +61890,9 @@ function registerComponents(ctx, jsx) {
60771
61890
  * step, the three tap listeners (the `LocalWrite` patch, the pointer tap that clears the keyboard
60772
61891
  * focus, and the tap that starts or ends the editing of a text field), the pointer door that opens
60773
61892
  * the keyboard inside the DOM listener, the key listener of the focus and `LocalWrite` as an input
60774
- * control, so the cursor shows a hand over a local-state button. On an agent page also the tap
60775
- * 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.
60776
61896
  *
60777
61897
  * The `Box` hook plays an element's enter hook and queues it, so the game's systems see its start
60778
61898
  * pose (F49); `ui.enter` starts its loop after them and before the frame is drawn, and plays the
@@ -60804,7 +61924,7 @@ function openRegistrations(ctx, jsx, layout) {
60804
61924
  })), ecs.onAdded(Tree, (entity) => {
60805
61925
  const place = projection.keyOf(entity);
60806
61926
  jsx.mountRoot(entity, place?.projection ?? "ui", ecs.get(entity, Layer)?.name ?? "ui");
60807
- }), 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) => {
60808
61928
  ctx.state.jsx.hints.push(hint);
60809
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));
60810
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)));
@@ -60863,6 +61983,7 @@ function stopUi(state) {
60863
61983
  state.jsx.released.clear();
60864
61984
  state.jsx.scrolls.clear();
60865
61985
  state.jsx.remount = false;
61986
+ state.jsx.asideRoots = 0;
60866
61987
  state.jsx.hints.length = 0;
60867
61988
  state.jsx.entering.list.length = 0;
60868
61989
  state.jsx.entering.count = 0;
@@ -61253,7 +62374,8 @@ function createJsxState() {
61253
62374
  written: 0,
61254
62375
  debugTaps: [],
61255
62376
  debugWarned: false,
61256
- overlays: emptySet()
62377
+ overlays: emptySet(),
62378
+ asideRoots: 0
61257
62379
  };
61258
62380
  }
61259
62381
  //#endregion
@@ -62838,6 +63960,7 @@ function cubicBezier(x1, y1, x2, y2) {
62838
63960
  * | assets | preloadDepth | 2 |
62839
63961
  * | assets | baseUrl | undefined |
62840
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 |
62841
63964
  * | anim | maxTracks | 2000 |
62842
63965
  * | anim | reducedMotion | false |
62843
63966
  * | i18n | locale | "en" |