@moku-labs/game 0.15.3 → 0.15.4

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.
@@ -3753,7 +3753,7 @@ function featureProblems(flows, features) {
3753
3753
  * sceneIdOf("board"); // undefined: a scene is an object with an id
3754
3754
  * ```
3755
3755
  */
3756
- function sceneIdOf(value) {
3756
+ function sceneIdOf$1(value) {
3757
3757
  if (typeof value !== "object" || value === null || !("id" in value)) return void 0;
3758
3758
  return typeof value.id === "string" ? value.id : void 0;
3759
3759
  }
@@ -3772,7 +3772,7 @@ function declaredScenes(features) {
3772
3772
  for (const list of lists) {
3773
3773
  if (!Array.isArray(list)) continue;
3774
3774
  for (const scene of list) {
3775
- const id = sceneIdOf(scene);
3775
+ const id = sceneIdOf$1(scene);
3776
3776
  if (id !== void 0) ids.add(id);
3777
3777
  }
3778
3778
  }
@@ -8374,6 +8374,32 @@ const NO_ROWS = {
8374
8374
  next: () => DONE
8375
8375
  };
8376
8376
  /**
8377
+ * Tells whether a system's query may match, before its run: every term has a store and none of them
8378
+ * is empty, which is the smallest store among the terms not being empty. A store met for the first
8379
+ * time is kept in the entry, so later checks only read sizes. An empty query may always match. An
8380
+ * entity that carries one term and not the others still passes: the walk finds nothing, as before.
8381
+ *
8382
+ * @param stores - Every component store of the world, by name.
8383
+ * @param entry - The registered system.
8384
+ * @returns False when a term has no entity: the system is skipped.
8385
+ */
8386
+ function mayMatch(stores, entry) {
8387
+ const names = entry.plan.names;
8388
+ const kept = entry.stores;
8389
+ const count = names.length;
8390
+ for (let index = 0; index < count; index += 1) {
8391
+ let store = kept[index];
8392
+ if (store === void 0) {
8393
+ const name = names[index];
8394
+ store = name === void 0 ? void 0 : stores.get(name);
8395
+ if (store === void 0) return false;
8396
+ kept[index] = store;
8397
+ }
8398
+ if (store.size === 0) return false;
8399
+ }
8400
+ return true;
8401
+ }
8402
+ /**
8377
8403
  * The rows the runner hands a system's `run`: its walk, reset; none for an empty query. A system
8378
8404
  * entry made without a walk gets one here, once.
8379
8405
  *
@@ -8467,6 +8493,113 @@ function rememberPlan(ctx, key, terms) {
8467
8493
  return plan;
8468
8494
  }
8469
8495
  //#endregion
8496
+ //#region src/plugins/world/ecs/scenes.ts
8497
+ /**
8498
+ * Creates the set of declared scene ids. Its own function: a collection is built outside the
8499
+ * exported declarations.
8500
+ *
8501
+ * @returns An empty set.
8502
+ */
8503
+ function emptyIds() {
8504
+ return /* @__PURE__ */ new Set();
8505
+ }
8506
+ /**
8507
+ * Reads the id of one entry of a feature's `scenes` list, without trusting the entry.
8508
+ *
8509
+ * @param entry - What the feature put in the list.
8510
+ * @returns The id, or `undefined` when the entry carries none.
8511
+ * @example
8512
+ * ```ts
8513
+ * sceneIdOf({ id: "board", layers: {} }); // "board"
8514
+ * ```
8515
+ */
8516
+ function sceneIdOf(entry) {
8517
+ if (typeof entry !== "object" || entry === null) return void 0;
8518
+ const id = entry.id;
8519
+ return typeof id === "string" ? id : void 0;
8520
+ }
8521
+ /**
8522
+ * Records the scene ids every feature declares, so the systems registered from now on have their
8523
+ * `scenes` checked. Called once at start, before the features' systems register.
8524
+ *
8525
+ * @param scene - The scene state.
8526
+ * @param features - The registered features.
8527
+ */
8528
+ function declareScenes(scene, features) {
8529
+ const declared = emptyIds();
8530
+ for (const feature of features) {
8531
+ const list = feature.description.scenes;
8532
+ if (!Array.isArray(list)) continue;
8533
+ for (const entry of list) {
8534
+ const id = sceneIdOf(entry);
8535
+ if (id !== void 0) declared.add(id);
8536
+ }
8537
+ }
8538
+ scene.declared = declared;
8539
+ }
8540
+ /**
8541
+ * Throws for a name in a system's `scenes` that no feature declares as a scene: such a system
8542
+ * would never run. Nothing is checked before start, when the declared scenes are not known yet.
8543
+ * The caller guards it for dev builds.
8544
+ *
8545
+ * @param scene - The scene state.
8546
+ * @param definition - The system being registered.
8547
+ * @throws {Error} When a name in `scenes` is not a declared scene.
8548
+ */
8549
+ function checkScenes(scene, definition) {
8550
+ const declared = scene.declared;
8551
+ if (declared === void 0 || definition.scenes === void 0) return;
8552
+ for (const name of definition.scenes) {
8553
+ if (declared.has(name)) continue;
8554
+ const known = [...declared].join(", ") || "none";
8555
+ throw new Error(`[game] System "${definition.name}" runs on the scene "${name}", which no feature declares.\n Name a scene a feature lists in its scenes (declared: ${known}).`);
8556
+ }
8557
+ }
8558
+ /**
8559
+ * Mounts a scene for the systems: the scene mounted before starts leaving. The same scene again
8560
+ * changes nothing.
8561
+ *
8562
+ * @param scene - The scene state.
8563
+ * @param id - The id of the scene now mounted.
8564
+ */
8565
+ function mountScene(scene, id) {
8566
+ const before = scene.mounted;
8567
+ if (before === id) return;
8568
+ if (before !== void 0 && !scene.leaving.includes(before)) scene.leaving.push(before);
8569
+ scene.mounted = id;
8570
+ }
8571
+ /**
8572
+ * Ends the leave of every leaving scene once nothing carries `Exiting`: the elements of the JSX
8573
+ * views of the scenes before played their exit. Called at the start of a phase while a scene leaves.
8574
+ *
8575
+ * @param state - The ecs branch of the plugin state.
8576
+ */
8577
+ function endLeaving(state) {
8578
+ const exiting = state.stores.get(Exiting.componentName);
8579
+ if (exiting !== void 0 && exiting.size > 0) return;
8580
+ const leaving = state.scene.leaving;
8581
+ while (leaving.length > 0) leaving.pop();
8582
+ }
8583
+ /**
8584
+ * Tells whether one of a system's scenes is mounted or leaving.
8585
+ *
8586
+ * @param scene - The scene state.
8587
+ * @param names - The system's `scenes`.
8588
+ * @returns True when the system may run.
8589
+ */
8590
+ function onScene(scene, names) {
8591
+ const { mounted, leaving } = scene;
8592
+ const count = names.length;
8593
+ const left = leaving.length;
8594
+ for (let index = 0; index < count; index += 1) {
8595
+ const name = names[index];
8596
+ if (name === void 0) continue;
8597
+ if (name === mounted) return true;
8598
+ for (let at = 0; at < left; at += 1) if (leaving[at] === name) return true;
8599
+ }
8600
+ return false;
8601
+ }
8602
+ //#endregion
8470
8603
  //#region src/plugins/world/ecs/schema.ts
8471
8604
  const notJson = null;
8472
8605
  /**
@@ -8546,294 +8679,6 @@ function schemaOf(types) {
8546
8679
  return [...types].map((type) => schemaOfType(type)).toSorted((left, right) => left.name.localeCompare(right.name));
8547
8680
  }
8548
8681
  //#endregion
8549
- //#region src/plugins/world/ecs/scope.ts
8550
- /** The keys of a description whose entries can make a feature active. */
8551
- const SOURCES = [
8552
- "nodes",
8553
- "flows",
8554
- "scenes",
8555
- "projections",
8556
- "ui"
8557
- ];
8558
- /** The release of a hold that holds nothing. */
8559
- const RELEASE_NOTHING = () => {};
8560
- /**
8561
- * Creates an empty table of owners. Its own function: a collection is built outside the exported
8562
- * declarations.
8563
- *
8564
- * @returns An empty map.
8565
- */
8566
- function emptyOwners$2() {
8567
- return /* @__PURE__ */ new Map();
8568
- }
8569
- /**
8570
- * Sizes the scope to the features of the game, in registration order: the only allocation of the
8571
- * scope, once per start. Whatever an earlier start left is dropped first.
8572
- *
8573
- * @param ctx - Domain context of the world plugin.
8574
- */
8575
- function openScope(ctx) {
8576
- const scope = ctx.state.ecs.scope;
8577
- const features = ctx.deps.flow.features.all();
8578
- const count = features.length;
8579
- resetScope(scope);
8580
- for (const [at, feature] of features.entries()) {
8581
- scope.names.push(feature.name);
8582
- scope.index.set(feature.name, at);
8583
- }
8584
- scope.onStack = new Uint8Array(count);
8585
- scope.active = new Uint8Array(count);
8586
- scope.holds = new Int32Array(count);
8587
- scope.views = new Int32Array(count);
8588
- scope.dirty = true;
8589
- }
8590
- /**
8591
- * Empties the scope in place, as a world that never started has it, and moves its generation on:
8592
- * a release kept past this changes nothing, also once the world started again.
8593
- *
8594
- * @param scope - The scope.
8595
- */
8596
- function resetScope(scope) {
8597
- scope.generation += 1;
8598
- scope.names.length = 0;
8599
- scope.index.clear();
8600
- scope.projections.clear();
8601
- scope.owners = void 0;
8602
- scope.ownersFailed = false;
8603
- scope.flow = void 0;
8604
- scope.onStack = /* @__PURE__ */ new Uint8Array(0);
8605
- scope.active = /* @__PURE__ */ new Uint8Array(0);
8606
- scope.holds = /* @__PURE__ */ new Int32Array(0);
8607
- scope.views = /* @__PURE__ */ new Int32Array(0);
8608
- scope.dirty = false;
8609
- scope.scoped = false;
8610
- }
8611
- /**
8612
- * The scope the systems of one feature register with: its index for `systemsRun: "active"`, -1
8613
- * otherwise. A value outside the type (a hand-written `features.register`) is reported and its
8614
- * systems always run.
8615
- *
8616
- * @param ctx - Domain context of the world plugin.
8617
- * @param at - The feature's index.
8618
- * @param feature - The feature and what it registered.
8619
- * @param feature.name - Feature name, for the log entry.
8620
- * @param feature.description - What the feature brought.
8621
- * @returns The scope of its systems.
8622
- */
8623
- function scopeOf(ctx, at, feature) {
8624
- const run = feature.description.systemsRun;
8625
- if (run === "active") return at;
8626
- if (run !== void 0 && run !== "always") ctx.log.warn("world:bad-feature-entry", {
8627
- feature: feature.name,
8628
- key: "systemsRun"
8629
- });
8630
- return -1;
8631
- }
8632
- /**
8633
- * Warns about a feature whose systems wait for it to be active while nothing can make it active:
8634
- * it has systems and `systemsRun: "active"`, and no node, flow, scene, projection or ui component.
8635
- * The caller guards it for dev builds.
8636
- *
8637
- * @param ctx - Domain context of the world plugin.
8638
- * @param feature - The feature and what it registered.
8639
- * @param feature.name - Feature name, for the warning.
8640
- * @param feature.description - What the feature brought.
8641
- */
8642
- function reportNeverActive(ctx, feature) {
8643
- const { name, description } = feature;
8644
- const systems = description.systems;
8645
- if (description.systemsRun !== "active") return;
8646
- if (!Array.isArray(systems) || systems.length === 0) return;
8647
- for (const key of SOURCES) {
8648
- const list = description[key];
8649
- if (Array.isArray(list) && list.length > 0) return;
8650
- }
8651
- ctx.log.warn("world:feature-never-active", {
8652
- feature: name,
8653
- message: `Feature "${name}" runs its systems only while it is active, but it has no node, flow, scene, projection or ui component, so nothing makes it active and its systems never run. Remove systemsRun: "active" from it.`
8654
- });
8655
- }
8656
- /**
8657
- * Builds the owner table of rule N from one `describe()`: flow id to node name to the index of the
8658
- * feature that owns the node. A node without an owner, or owned by a name that is not a feature,
8659
- * is left out.
8660
- *
8661
- * @param graph - The described graph.
8662
- * @param index - Feature name to index.
8663
- * @returns The owner table.
8664
- */
8665
- function ownersOf$1(graph, index) {
8666
- const owners = emptyOwners$2();
8667
- for (const [id, flow] of Object.entries(graph.flows)) {
8668
- const nodes = emptyOwners$2();
8669
- for (const [node, info] of Object.entries(flow.nodes)) {
8670
- const at = info.owner === void 0 ? void 0 : index.get(info.owner);
8671
- if (at !== void 0) nodes.set(node, at);
8672
- }
8673
- if (nodes.size > 0) owners.set(id, nodes);
8674
- }
8675
- return owners;
8676
- }
8677
- /**
8678
- * Builds the owner table once, at the first stack that is not empty: the graph is sealed by
8679
- * `flow.run()`. A `describe()` that throws is logged once and makes every feature count as on the
8680
- * stack (fail-safe).
8681
- *
8682
- * @param ctx - Domain context of the world plugin.
8683
- * @param scope - The scope.
8684
- */
8685
- function buildOwners(ctx, scope) {
8686
- try {
8687
- scope.owners = ownersOf$1(ctx.deps.flow.describe(), scope.index);
8688
- } catch (error) {
8689
- scope.ownersFailed = true;
8690
- ctx.log.error("world:scope-owners-failed", {}, asError$3(error));
8691
- }
8692
- }
8693
- /**
8694
- * Sets the stack flags of rule N from the frames of one flow state, every frame counted, the
8695
- * outer frames of sub-flows included.
8696
- *
8697
- * @param ctx - Domain context of the world plugin.
8698
- * @param scope - The scope.
8699
- * @param stack - The frames of the flow state.
8700
- */
8701
- function readStack$1(ctx, scope, stack) {
8702
- const onStack = scope.onStack;
8703
- const depth = stack.length;
8704
- onStack.fill(0);
8705
- if (depth === 0) return;
8706
- if (scope.owners === void 0 && !scope.ownersFailed) buildOwners(ctx, scope);
8707
- const owners = scope.owners;
8708
- if (owners === void 0) {
8709
- onStack.fill(1);
8710
- return;
8711
- }
8712
- for (let index = 0; index < depth; index += 1) {
8713
- const frame = stack[index];
8714
- const at = frame === void 0 ? void 0 : owners.get(frame.flow)?.get(frame.node);
8715
- if (at !== void 0) onStack[at] = 1;
8716
- }
8717
- }
8718
- /**
8719
- * Reads the flow stack again when `flow.state()` answers a new object. The state is memoised, so a
8720
- * frame in which the graph did not move costs one call and one comparison.
8721
- *
8722
- * @param ctx - Domain context of the world plugin.
8723
- * @param scope - The scope.
8724
- */
8725
- function readSources(ctx, scope) {
8726
- const flowState = ctx.deps.flow.state();
8727
- if (flowState === scope.flow) return;
8728
- scope.flow = flowState;
8729
- readStack$1(ctx, scope, flowState.stack);
8730
- scope.dirty = true;
8731
- }
8732
- /**
8733
- * Evaluates the active set at the start of a phase that runs systems: a feature is active while it
8734
- * is on the stack, held, or has a view. Nothing moved: one call, one comparison and one flag test.
8735
- *
8736
- * @param ctx - Domain context of the world plugin.
8737
- */
8738
- function refreshScope(ctx) {
8739
- const scope = ctx.state.ecs.scope;
8740
- readSources(ctx, scope);
8741
- if (!scope.dirty) return;
8742
- const { onStack, holds, views, active } = scope;
8743
- const count = scope.names.length;
8744
- for (let at = 0; at < count; at += 1) active[at] = onStack[at] === 1 || (holds[at] ?? 0) > 0 || (views[at] ?? 0) > 0 ? 1 : 0;
8745
- scope.dirty = false;
8746
- }
8747
- /**
8748
- * The release of one hold: it takes the hold back once, never below 0, and only in the run it was
8749
- * taken in. A release kept across a stop and a start finds a newer generation and changes nothing
8750
- * (spec 28 W7.1).
8751
- *
8752
- * @param scope - The scope.
8753
- * @param at - The held feature's index.
8754
- * @returns The idempotent release.
8755
- */
8756
- function releaseOf(scope, at) {
8757
- const hold = {
8758
- released: false,
8759
- generation: scope.generation
8760
- };
8761
- return () => {
8762
- if (hold.released || hold.generation !== scope.generation) return;
8763
- hold.released = true;
8764
- scope.holds[at] = Math.max(0, (scope.holds[at] ?? 0) - 1);
8765
- scope.dirty = true;
8766
- };
8767
- }
8768
- /**
8769
- * Takes one hold on a feature. A name that is not a feature holds nothing; a dev build warns.
8770
- *
8771
- * @param ctx - Domain context of the world plugin.
8772
- * @param feature - The feature to keep active.
8773
- * @returns The release.
8774
- */
8775
- function holdFeature(ctx, feature) {
8776
- const scope = ctx.state.ecs.scope;
8777
- const at = scope.index.get(feature);
8778
- if (at === void 0) {
8779
- if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__) ctx.log.warn("world:hold-unknown-feature", { feature });
8780
- return RELEASE_NOTHING;
8781
- }
8782
- scope.holds[at] = (scope.holds[at] ?? 0) + 1;
8783
- scope.dirty = true;
8784
- return releaseOf(scope, at);
8785
- }
8786
- /**
8787
- * Whether a feature is active now, from the sources as they are at this moment. It leaves the
8788
- * active set of the running phase alone: a source that moves inside a phase counts for the
8789
- * systems from the next phase.
8790
- *
8791
- * @param ctx - Domain context of the world plugin.
8792
- * @param feature - The feature name.
8793
- * @returns True while it is on the stack, held or has a view; false for a name that is not a feature.
8794
- */
8795
- function isFeatureActive(ctx, feature) {
8796
- const scope = ctx.state.ecs.scope;
8797
- const at = scope.index.get(feature);
8798
- if (at === void 0) return false;
8799
- readSources(ctx, scope);
8800
- return scope.onStack[at] === 1 || (scope.holds[at] ?? 0) > 0 || (scope.views[at] ?? 0) > 0;
8801
- }
8802
- /**
8803
- * Counts a new view of a projection for the feature that registered it. A projection no feature
8804
- * registered counts for nobody.
8805
- *
8806
- * @param scope - The scope.
8807
- * @param projection - The projection name.
8808
- */
8809
- function viewCame(scope, projection) {
8810
- const at = scope.projections.get(projection);
8811
- if (at === void 0) return;
8812
- scope.views[at] = (scope.views[at] ?? 0) + 1;
8813
- scope.dirty = true;
8814
- }
8815
- /**
8816
- * Takes back the count of a despawned view, never below 0.
8817
- *
8818
- * @param scope - The scope.
8819
- * @param projection - The projection name.
8820
- */
8821
- function viewLeft(scope, projection) {
8822
- const at = scope.projections.get(projection);
8823
- if (at === void 0) return;
8824
- scope.views[at] = Math.max(0, (scope.views[at] ?? 0) - 1);
8825
- scope.dirty = true;
8826
- }
8827
- /**
8828
- * Drops every view count: the projections were cleared.
8829
- *
8830
- * @param scope - The scope.
8831
- */
8832
- function viewsCleared(scope) {
8833
- scope.views.fill(0);
8834
- scope.dirty = true;
8835
- }
8836
- //#endregion
8837
8682
  //#region src/plugins/world/ecs/systems.ts
8838
8683
  /** The one phase that runs in every mode, because `renderer.sync` lives in it. */
8839
8684
  const ALWAYS = "sync";
@@ -8923,18 +8768,29 @@ function frameSnapshot(ctx, time) {
8923
8768
  return snapshot;
8924
8769
  }
8925
8770
  /**
8926
- * Registers a system at the end of its phase list and returns the remover. Its query is resolved
8927
- * here, once: a later change to the definition's `query` array is not seen. A system of a feature
8928
- * with `systemsRun: "active"` carries the feature's index as its scope and runs only while the
8929
- * feature is active; `ecs.system` passes none, so a system registered by hand always runs.
8771
+ * An empty slot for each term of a plan: the stores its check keeps, filled with `push`, so the
8772
+ * list is a packed array of its exact length from the start.
8773
+ *
8774
+ * @param count - The number of terms.
8775
+ * @returns The slots, all `undefined`.
8776
+ */
8777
+ function storeSlots(count) {
8778
+ const slots = [];
8779
+ for (let index = 0; index < count; index += 1) slots.push(void 0);
8780
+ return slots;
8781
+ }
8782
+ /**
8783
+ * Registers a system at the end of its phase list and returns the remover. Its query and its
8784
+ * `scenes` are read here, once: a later change to the definition is not seen. A dev build checks
8785
+ * the `scenes` against the scenes the features declare, once the world started.
8930
8786
  *
8931
8787
  * @param ctx - Domain context of the world plugin.
8932
8788
  * @param definition - The system.
8933
- * @param scope - The index of its feature in the scope, or -1 for a system that always runs.
8934
8789
  * @returns The remover; calling it twice is a no-op.
8935
- * @throws {Error} When a system of that name is already registered.
8790
+ * @throws {Error} When a system of that name is already registered, and in a dev build for a
8791
+ * name in `scenes` that no feature declares.
8936
8792
  */
8937
- function registerSystem(ctx, definition, scope = -1) {
8793
+ function registerSystem(ctx, definition) {
8938
8794
  const state = ctx.state.ecs;
8939
8795
  for (const phase of [
8940
8796
  "input",
@@ -8942,18 +8798,19 @@ function registerSystem(ctx, definition, scope = -1) {
8942
8798
  "layout",
8943
8799
  "sync"
8944
8800
  ]) if (state.systems[phase].some((entry) => entry.definition.name === definition.name)) throw new Error(`[game] System "${definition.name}" is registered twice.\n Give the second system another name, or remove the first one.`);
8801
+ if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__) checkScenes(state.scene, definition);
8945
8802
  const plan = planOf(ctx, definition.query);
8946
8803
  const entry = {
8947
8804
  definition,
8948
8805
  frame: ctx.deps.time.snapshot().frame,
8949
8806
  plan,
8950
- scope,
8807
+ stores: storeSlots(plan.names.length),
8808
+ scenes: definition.scenes === void 0 ? void 0 : [...definition.scenes],
8951
8809
  skipped: false,
8952
8810
  walk: plan.names.length === 0 ? void 0 : new SystemWalk(ctx, plan)
8953
8811
  };
8954
8812
  const list = state.systems[definition.phase];
8955
8813
  list.push(entry);
8956
- if (scope !== -1) state.scope.scoped = true;
8957
8814
  if (state.timing !== void 0) giveRow(state, state.timing, definition.name);
8958
8815
  return () => {
8959
8816
  const at = list.indexOf(entry);
@@ -9002,19 +8859,19 @@ function phaseContextOf(ctx, world, time) {
9002
8859
  return known;
9003
8860
  }
9004
8861
  /**
9005
- * Tells whether a system of a scoped feature runs in this phase. One whose feature is not active is
9006
- * skipped in its slot and remembers it; one that runs is told whether its runs before were
9007
- * skipped. No walk, no mark and no clock read happen for a skipped system: the check comes before
9008
- * all three. The caller sets `resumed` back to false after the run, so a system that always runs
9009
- * reads false without a write of its own.
8862
+ * Tells whether a system runs in this phase: one of its `scenes` is mounted or leaving, and no
8863
+ * term of its query has an empty store. A skipped system remembers it; one that runs is told in
8864
+ * `resumed` whether its last chance was skipped. No walk, no mark and no clock read happen for a
8865
+ * skipped system: the check comes before all three.
9010
8866
  *
9011
- * @param active - The active flags of the features, evaluated at the start of the phase.
9012
- * @param entry - The system, whose scope is a feature index.
8867
+ * @param state - The ecs branch of the plugin state.
8868
+ * @param entry - The system.
9013
8869
  * @param context - What its `run` receives; `resumed` is written here.
9014
8870
  * @returns True when the system runs.
9015
8871
  */
9016
- function wakes(active, entry, context) {
9017
- if (active[entry.scope] === 0) {
8872
+ function wakes(state, entry, context) {
8873
+ const scenes = entry.scenes;
8874
+ if (scenes !== void 0 && !onScene(state.scene, scenes) || !mayMatch(state.stores, entry)) {
9018
8875
  entry.skipped = true;
9019
8876
  return false;
9020
8877
  }
@@ -9023,21 +8880,6 @@ function wakes(active, entry, context) {
9023
8880
  return true;
9024
8881
  }
9025
8882
  /**
9026
- * Runs one system of a scoped feature when its feature is active, and leaves `resumed` false for
9027
- * the systems after it.
9028
- *
9029
- * @param ctx - Domain context of the world plugin.
9030
- * @param active - The active flags of the features.
9031
- * @param entry - The system.
9032
- * @param context - What its `run` receives.
9033
- * @param phase - The phase, for the report of a throw.
9034
- */
9035
- function runScoped(ctx, active, entry, context, phase) {
9036
- if (!wakes(active, entry, context)) return;
9037
- guardedRun(ctx, entry, context, phase);
9038
- context.resumed = false;
9039
- }
9040
- /**
9041
8883
  * Runs one system, reporting a throw: the try lives here, so the loop of a phase holds none.
9042
8884
  *
9043
8885
  * @param ctx - Domain context of the world plugin.
@@ -9057,9 +8899,9 @@ function guardedRun(ctx, entry, context, phase) {
9057
8899
  }
9058
8900
  /**
9059
8901
  * Runs the systems of one phase and flushes the commands they queued. A throwing system is
9060
- * reported and skipped for this frame; the frame goes on. When a feature scopes its systems, the
9061
- * active set is evaluated first, from the sources as they stand at the start of the phase; a
9062
- * system of a feature that is not active is skipped in its slot.
8902
+ * reported and skipped for this frame; the frame goes on. A system whose query has no entity, or
8903
+ * none of whose `scenes` is mounted or leaving, is skipped in its slot. While a scene leaves, the
8904
+ * phase first ends the leave when nothing carries `Exiting` any more.
9063
8905
  *
9064
8906
  * @param ctx - Domain context of the world plugin.
9065
8907
  * @param world - The ecs API a system's `run` receives.
@@ -9069,16 +8911,14 @@ function guardedRun(ctx, entry, context, phase) {
9069
8911
  function runPhase(ctx, world, phase, time) {
9070
8912
  const state = ctx.state.ecs;
9071
8913
  if (phase !== ALWAYS && effectiveMode(ctx) !== "live") return;
9072
- if (state.scope.scoped) refreshScope(ctx);
8914
+ if (state.scene.leaving.length > 0) endLeaving(state);
9073
8915
  const context = phaseContextOf(ctx, world, time);
9074
8916
  const entries = state.systems[phase];
9075
- const active = state.scope.active;
9076
8917
  state.running = phase;
9077
8918
  if (state.timing === void 0) for (let index = 0; index < entries.length; index += 1) {
9078
8919
  const entry = entries[index];
9079
8920
  if (entry === void 0 || entry.frame >= time.frame) continue;
9080
- if (entry.scope === -1) guardedRun(ctx, entry, context, phase);
9081
- else runScoped(ctx, active, entry, context, phase);
8921
+ if (wakes(state, entry, context)) guardedRun(ctx, entry, context, phase);
9082
8922
  }
9083
8923
  else runTimed(ctx, state.timing, entries, context, phase, time);
9084
8924
  state.running = void 0;
@@ -9098,26 +8938,24 @@ function runPhase(ctx, world, phase, time) {
9098
8938
  * @param time - The frame's `Time`.
9099
8939
  */
9100
8940
  function runTimed(ctx, timing, entries, context, phase, time) {
8941
+ const state = ctx.state.ecs;
9101
8942
  const count = entries.length;
9102
- const active = ctx.state.ecs.scope.active;
9103
8943
  for (let index = 0; index < count; index += 1) {
9104
8944
  const entry = entries[index];
9105
8945
  if (entry === void 0 || entry.frame >= time.frame) continue;
9106
- if (entry.scope !== -1 && !wakes(active, entry, context)) continue;
9107
- const row = rowOf(ctx.state.ecs, entry.definition.name);
8946
+ if (!wakes(state, entry, context)) continue;
8947
+ const row = rowOf(state, entry.definition.name);
9108
8948
  const started = timing.clock();
9109
8949
  guardedRun(ctx, entry, context, phase);
9110
8950
  timing.ms[row] = (timing.ms[row] ?? 0) + timing.clock() - started;
9111
- context.resumed = false;
9112
8951
  }
9113
8952
  }
9114
8953
  //#endregion
9115
8954
  //#region src/plugins/world/ecs/api.ts
9116
8955
  /**
9117
- * Empties the world in place: entities, components, resources, systems, hooks, the frame history
9118
- * and the view counts of the scope. The holds stay: the plugins above still show their scene or
9119
- * root. The owner table stays: the graph did not change. The state object keeps its identity, so
9120
- * every view on it stays valid.
8956
+ * Empties the world in place: entities, components, resources, systems, hooks and the frame
8957
+ * history. The mounted scene stays: `scenes` still shows it. The state object keeps its identity,
8958
+ * so every view on it stays valid.
9121
8959
  *
9122
8960
  * @param ctx - Domain context of the world plugin.
9123
8961
  */
@@ -9148,7 +8986,6 @@ function clearWorldState(ctx) {
9148
8986
  state.mode = "live";
9149
8987
  state.frameSnapshot = void 0;
9150
8988
  resetHistory(state);
9151
- viewsCleared(state.scope);
9152
8989
  }
9153
8990
  /**
9154
8991
  * Creates the ecs module: the public API a game and the plugins above call, plus the four methods
@@ -9161,8 +8998,6 @@ function createEcsApi(ctx) {
9161
8998
  const state = ctx.state.ecs;
9162
8999
  const api = {
9163
9000
  system: (definition) => registerSystem(ctx, definition),
9164
- hold: (feature) => holdFeature(ctx, feature),
9165
- isActive: (feature) => isFeatureActive(ctx, feature),
9166
9001
  spawn: (owner, components) => {
9167
9002
  const entity = reserveEntity(state, owner);
9168
9003
  queueOrApply(ctx, {
@@ -9242,6 +9077,9 @@ function createEcsApi(ctx) {
9242
9077
  setMode: (mode) => {
9243
9078
  state.mode = mode;
9244
9079
  },
9080
+ setScene: (scene) => {
9081
+ mountScene(state.scene, scene);
9082
+ },
9245
9083
  typeOf: (name) => asComponent$2(state.types.get(name)),
9246
9084
  snapshot: () => snapshotWorld(state, effectiveMode(ctx)),
9247
9085
  diff: (from, to) => diffHistory(state.history, from, to),
@@ -9266,15 +9104,6 @@ function createEcsApi(ctx) {
9266
9104
  },
9267
9105
  clear: () => {
9268
9106
  clearWorldState(ctx);
9269
- },
9270
- viewCame: (projection) => {
9271
- viewCame(state.scope, projection);
9272
- },
9273
- viewLeft: (projection) => {
9274
- viewLeft(state.scope, projection);
9275
- },
9276
- viewsCleared: () => {
9277
- viewsCleared(state.scope);
9278
9107
  }
9279
9108
  };
9280
9109
  return api;
@@ -9388,29 +9217,24 @@ function registerEach(ctx, feature, key, accepts, register) {
9388
9217
  }
9389
9218
  /**
9390
9219
  * Registers what every feature brought, in feature order: components first, so their schema is
9391
- * known, then systems, then projections. A feature's index is its place in the list: the systems
9392
- * of a feature with `systemsRun: "active"` carry it as their scope, and each projection is counted
9393
- * for it (rule V). A dev build warns about a feature nothing can make active.
9220
+ * known, then systems, then projections. The scene ids of every feature are read first, so a dev
9221
+ * build checks the `scenes` of each system as it registers.
9394
9222
  *
9395
9223
  * @param ctx - Domain context of the world plugin.
9224
+ * @param ecs - The ecs module.
9396
9225
  * @param projection - The projection module.
9397
9226
  */
9398
- function registerFeatures(ctx, projection) {
9399
- const projections = ctx.state.ecs.scope.projections;
9400
- for (const [at, feature] of ctx.deps.flow.features.all().entries()) {
9401
- const scope = scopeOf(ctx, at, feature);
9227
+ function registerFeatures(ctx, ecs, projection) {
9228
+ const features = ctx.deps.flow.features.all();
9229
+ declareScenes(ctx.state.ecs.scene, features);
9230
+ for (const feature of features) {
9402
9231
  registerEach(ctx, feature, "components", isComponentEntry, (entry) => registerType(ctx, entry));
9403
- registerEach(ctx, feature, "systems", isSystemEntry, (entry) => registerSystem(ctx, entry, scope));
9404
- registerEach(ctx, feature, "projections", isProjectionEntry, (entry) => {
9405
- projection.register(entry);
9406
- projections.set(entry.name, at);
9407
- });
9408
- if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__) reportNeverActive(ctx, feature);
9232
+ registerEach(ctx, feature, "systems", isSystemEntry, (entry) => ecs.system(entry));
9233
+ registerEach(ctx, feature, "projections", isProjectionEntry, (entry) => projection.register(entry));
9409
9234
  }
9410
9235
  }
9411
9236
  /**
9412
- * Connects the world in `onStart`: it sizes the scope to the features, reads every feature
9413
- * description, registers the five frame
9237
+ * Connects the world in `onStart`: it reads every feature description, registers the five frame
9414
9238
  * callbacks of the phases and listens to the released hints. In a dev build the `signals`
9415
9239
  * callback records the frame history before it clears the change sets; in an agent build the
9416
9240
  * timing table starts with the page clock of `time` and the `input` callback zeroes it each
@@ -9431,8 +9255,7 @@ function connectWorld(ctx) {
9431
9255
  const clock = time.agentClock();
9432
9256
  if (clock !== void 0) startTiming(worldCtx.state.ecs, clock);
9433
9257
  }
9434
- openScope(worldCtx);
9435
- registerFeatures(worldCtx, projection);
9258
+ registerFeatures(worldCtx, ecs, projection);
9436
9259
  ecs.resource(Now).now = clock.now();
9437
9260
  worldCtx.state.ecs.offFrame.push(ecs.onOwnerLeft((owner) => projection.ownerLeft(owner)), time.onFrame("input", (frame) => {
9438
9261
  ecs.resource(Now).now = clock.now();
@@ -9450,8 +9273,8 @@ function connectWorld(ctx) {
9450
9273
  }
9451
9274
  /**
9452
9275
  * Empties the world in `onStop`: the frame callbacks and the hint listener are removed, then
9453
- * every track, view, queued view, entity and resource is dropped, the scope and the frame history
9454
- * are reset.
9276
+ * every track, view, queued view, entity and resource is dropped, and the scenes and the frame
9277
+ * history are reset.
9455
9278
  * No `onRemoved` fires, because `renderer` stopped earlier and destroyed its own objects.
9456
9279
  *
9457
9280
  * @param state - The plugin state, the only thing a teardown context carries.
@@ -9502,7 +9325,9 @@ function clearWorld(state) {
9502
9325
  state.ecs.mode = "live";
9503
9326
  state.ecs.timing = void 0;
9504
9327
  state.ecs.timingSlots.clear();
9505
- resetScope(state.ecs.scope);
9328
+ state.ecs.scene.mounted = void 0;
9329
+ state.ecs.scene.leaving.length = 0;
9330
+ state.ecs.scene.declared = void 0;
9506
9331
  resetHistory(state.ecs);
9507
9332
  }
9508
9333
  //#endregion
@@ -10415,7 +10240,7 @@ function despawnView(pctx, mounted, view) {
10415
10240
  view.handles = [];
10416
10241
  cancelTracks(pctx, view.entity);
10417
10242
  const state = pctx.ctx.state.projection;
10418
- if (state.byEntity.delete(view.entity)) pctx.deps.views.left(view.projection);
10243
+ state.byEntity.delete(view.entity);
10419
10244
  state.mutes.delete(view.entity);
10420
10245
  state.rests.delete(view.entity);
10421
10246
  state.leaving.set(view.entity, view.cause);
@@ -10791,7 +10616,6 @@ function enterEntry(pctx, spec, mounted, entry) {
10791
10616
  const view = createViewRecord(entity, spec.name, entry.key, entry.item, values, entry.direct ? "mount" : "change");
10792
10617
  mounted.live.set(entry.key, view);
10793
10618
  pctx.ctx.state.projection.byEntity.set(entity, view);
10794
- pctx.deps.views.came(spec.name);
10795
10619
  pctx.ctx.state.projection.membershipVersion += 1;
10796
10620
  const hook = spec.motion?.enter;
10797
10621
  const loop = spec.motion?.loop;
@@ -11604,7 +11428,6 @@ function createProjectionApi(ctx, deps) {
11604
11428
  state.mounted.clear();
11605
11429
  state.specs.clear();
11606
11430
  state.byEntity.clear();
11607
- pctx.deps.views.cleared();
11608
11431
  state.mutes.clear();
11609
11432
  state.rests.clear();
11610
11433
  state.keys.clear();
@@ -11644,12 +11467,7 @@ function createModules$2(ctx) {
11644
11467
  ecs,
11645
11468
  projection: createProjectionApi(ctx, {
11646
11469
  ecs,
11647
- components: WORLD_COMPONENTS,
11648
- views: {
11649
- came: ecs.viewCame,
11650
- left: ecs.viewLeft,
11651
- cleared: ecs.viewsCleared
11652
- }
11470
+ components: WORLD_COMPONENTS
11653
11471
  })
11654
11472
  };
11655
11473
  }
@@ -11684,8 +11502,6 @@ function withKeys(diff, ecs, projection) {
11684
11502
  function exposeEcs(ecs, projection) {
11685
11503
  return {
11686
11504
  system: ecs.system,
11687
- hold: ecs.hold,
11688
- isActive: ecs.isActive,
11689
11505
  spawn: ecs.spawn,
11690
11506
  despawn: ecs.despawn,
11691
11507
  despawnOwnedBy: ecs.despawnOwnedBy,
@@ -11709,6 +11525,7 @@ function exposeEcs(ecs, projection) {
11709
11525
  ecs.setMode(mode);
11710
11526
  if (ecs.mode() === "fast") projection.flushAll();
11711
11527
  },
11528
+ setScene: ecs.setScene,
11712
11529
  snapshot: ecs.snapshot,
11713
11530
  diff: (from, to) => withKeys(ecs.diff(from, to), ecs, projection),
11714
11531
  schema: ecs.schema
@@ -11807,26 +11624,16 @@ function emptyMap$10() {
11807
11624
  return /* @__PURE__ */ new Map();
11808
11625
  }
11809
11626
  /**
11810
- * Creates the scope of a world that has not started: no feature, no table of owners, typed arrays
11811
- * of length 0. `onStart` sizes it once to the feature count.
11627
+ * Creates the scene state of a world no scene was mounted in: nothing mounted, nothing leaving, the
11628
+ * declared scenes unknown until start.
11812
11629
  *
11813
- * @returns An empty scope.
11630
+ * @returns An empty scene state.
11814
11631
  */
11815
- function emptyScope() {
11632
+ function emptyScene() {
11816
11633
  return {
11817
- names: [],
11818
- index: /* @__PURE__ */ new Map(),
11819
- projections: /* @__PURE__ */ new Map(),
11820
- owners: void 0,
11821
- ownersFailed: false,
11822
- flow: void 0,
11823
- onStack: /* @__PURE__ */ new Uint8Array(0),
11824
- holds: /* @__PURE__ */ new Int32Array(0),
11825
- views: /* @__PURE__ */ new Int32Array(0),
11826
- active: /* @__PURE__ */ new Uint8Array(0),
11827
- dirty: false,
11828
- scoped: false,
11829
- generation: 0
11634
+ mounted: void 0,
11635
+ leaving: [],
11636
+ declared: void 0
11830
11637
  };
11831
11638
  }
11832
11639
  /**
@@ -11872,7 +11679,7 @@ function createEcsState() {
11872
11679
  queryPlans: emptyMap$10(),
11873
11680
  walkLists: [],
11874
11681
  despawns: 0,
11875
- scope: emptyScope()
11682
+ scene: emptyScene()
11876
11683
  };
11877
11684
  }
11878
11685
  //#endregion
@@ -13012,336 +12819,486 @@ function watchPixelRatio(ctx) {
13012
12819
  state.cleanups.push(() => query?.removeEventListener("change", follow));
13013
12820
  }
13014
12821
  //#endregion
13015
- //#region src/plugins/renderer/host/sdf-text.ts
12822
+ //#region src/plugins/renderer/host/sdf-batcher.ts
12823
+ /** The key of the glyph record on the proxy graphics of every bitmap text the renderer's pipe lays out. */
12824
+ const SDF = Symbol("moku-sdf-glyphs");
12825
+ /** The name the renderer's batcher of distance-field glyphs registers under, and the element's `batcherName`. */
12826
+ const SDF_BATCHER = "moku-sdf";
12827
+ /** The name of Pixi's default batcher, which an element goes back to. */
12828
+ const DEFAULT_BATCHER = "default";
12829
+ /** Floats of one vertex: position 2, uv 2, colour 1 (unorm8x4), texture id and rounding 1 (uint16x2), distance 1. */
12830
+ const VERTEX_SIZE = 7;
12831
+ /** Bytes of one vertex. */
12832
+ const STRIDE = 28;
12833
+ /** The name of the renderer's batched SDF program, on both backends. */
12834
+ const PROGRAM_NAME = "moku-sdf-batch";
12835
+ /**
12836
+ * The renderer's piece of the high shader: the glyph's edge width from the vertex, at the scale of the render group,
12837
+ * and the coverage with the alpha applied once. The template's `outColor * vColor` applies the colour and every alpha.
12838
+ */
12839
+ const SDF_BIT = {
12840
+ name: "moku-sdf-batch-bit",
12841
+ vertex: {
12842
+ header: `
12843
+ @in aSdfDistance: f32;
12844
+ @out @interpolate(flat) vSdfDistance: f32;
12845
+ `,
12846
+ main: `
12847
+ let sdfWorld = globalUniforms.uWorldTransformMatrix;
12848
+ vSdfDistance = aSdfDistance * (length(sdfWorld[0].xy) + length(sdfWorld[1].xy)) * 0.5;
12849
+ `
12850
+ },
12851
+ fragment: {
12852
+ header: `
12853
+ @in @interpolate(flat) vSdfDistance: f32;
12854
+ `,
12855
+ main: `
12856
+ outColor = vec4<f32>(calculateMSDFAlpha(outColor, vec4<f32>(vColor.rgb / max(vColor.a, 1e-4), 1.0), vSdfDistance));
12857
+ `
12858
+ }
12859
+ };
13016
12860
  /**
13017
- * The fragment step of the renderer's WGSL SDF shader, in place of Pixi's
13018
- * `calculateMSDFAlpha(outColor, vColor, …)`. The coverage is the glyph's alone and its gamma
13019
- * leans on the true luma of the colour; the template's `outColor * vColor` applies the alpha.
13020
- */
13021
- const SINGLE_ALPHA_STEP = `
13022
- outColor = vec4<f32>(calculateMSDFAlpha(outColor, vec4<f32>(vColor.rgb / max(vColor.a, 1e-4), 1.0), localUniforms.uDistance));
13023
- `;
13024
- /** The same fragment step in GLSL, for the WebGL program. */
13025
- const SINGLE_ALPHA_STEP_GL = `
13026
- outColor = vec4(calculateMSDFAlpha(outColor, vec4(vColor.rgb / max(vColor.a, 1e-4), 1.0), uDistance));
13027
- `;
13028
- /** The name of the renderer's local-uniform piece, apart from Pixi's in its shader cache. */
13029
- const TEXT_BIT_NAME = "local-uniform-msdf-single-alpha-bit";
13030
- /** The name of the renderer's SDF program, on both backends. */
13031
- const PROGRAM_NAME = "sdf-shader-single-alpha";
12861
+ * The same piece in GLSL, for the WebGL program. Pixi compiles its batch programs as GLSL ES 1.00 (its `in` and `out`
12862
+ * are macros), where `flat` is a reserved word: the distance is a plain varying, the same at the three vertices of a
12863
+ * glyph's triangle, as Pixi's own texture id is.
12864
+ */
12865
+ const SDF_BIT_GL = {
12866
+ name: "moku-sdf-batch-bit",
12867
+ vertex: {
12868
+ header: `
12869
+ in float aSdfDistance;
12870
+ out float vSdfDistance;
12871
+ `,
12872
+ main: `
12873
+ vSdfDistance = aSdfDistance * (length(uWorldTransformMatrix[0].xy) + length(uWorldTransformMatrix[1].xy)) * 0.5;
12874
+ `
12875
+ },
12876
+ fragment: {
12877
+ header: `
12878
+ in float vSdfDistance;
12879
+ `,
12880
+ main: `
12881
+ outColor = vec4(calculateMSDFAlpha(outColor, vec4(vColor.rgb / max(vColor.a, 1e-4), 1.0), vSdfDistance));
12882
+ `
12883
+ }
12884
+ };
13032
12885
  /**
13033
- * The scale of a transform as Pixi's `_updateDistanceField` measures it: the mean length of its two axes. Pure.
12886
+ * The edge width one vertex of an element carries: Pixi's distance at the element's scale in its render group, times
12887
+ * the resolution. A text's group transform is what Pixi scales by; the render group's own scale is the shader's.
13034
12888
  *
13035
- * @param linear - The linear part of a transform.
13036
- * @returns The scale.
13037
- * @example
13038
- * ```ts
13039
- * scaleOf({ a: 0.25, b: 0, c: 0, d: 0.25 }); // 0.25
13040
- * ```
12889
+ * @param element - The element.
12890
+ * @param a - `a` of its group transform.
12891
+ * @param b - `b` of its group transform.
12892
+ * @param c - `c` of its group transform.
12893
+ * @param d - `d` of its group transform.
12894
+ * @param density - Device pixels per CSS pixel.
12895
+ * @returns The distance, device px at the render group's scale 1; 0 for an element without glyphs.
13041
12896
  */
13042
- function scaleOf$1(linear) {
13043
- const { a, b, c, d } = linear;
13044
- return (Math.sqrt(a * a + b * b) + Math.sqrt(c * c + d * d)) / 2;
12897
+ function distanceOf(element, a, b, c, d, density) {
12898
+ const glyphs = element.renderable?.[SDF];
12899
+ if (glyphs === void 0) return 0;
12900
+ return glyphs.perScale * (Math.sqrt(a * a + b * b) + Math.sqrt(c * c + d * d)) * density / 2;
13045
12901
  }
13046
12902
  /**
13047
- * The key the state of a text's `uDistance` hangs under on its uniforms: a symbol, not enumerable, so Pixi's walk of
13048
- * the uniforms (`for…in` on WebGL) and a deep compare see the four uniforms alone.
13049
- */
13050
- const DISTANCE = Symbol("sdf-text-distance");
13051
- /**
13052
- * Pixi's read of `uDistance`, at every upload: the distance per unit of scale at the text's world scale, or Pixi's last
13053
- * write without one, times the renderer's resolution.
12903
+ * Keeps one shader per batch texture limit: made the first time a limit is asked for, shared by every batcher after.
12904
+ * Its own function, because lint rule L5 refuses a collection built inside an exported declaration.
13054
12905
  *
13055
- * @param this - The text's uniforms.
13056
- * @returns The distance in device pixels.
12906
+ * @param make - Makes the shader of one texture limit.
12907
+ * @returns The shader for a texture limit.
13057
12908
  */
13058
- function readDistance() {
13059
- const state = this[DISTANCE];
13060
- const perScale = state.cell[1] ?? 0;
13061
- const text = state.text;
13062
- const distance = (text === void 0 || perScale === 0 ? state.cell[0] ?? 0 : perScale * scaleOf$1(text.worldTransform)) * state.density.resolution;
13063
- const answer = state.answer;
13064
- if (answer !== void 0 && distance === state.cell[2]) return answer;
13065
- state.cell[2] = distance;
13066
- state.answer = distance;
13067
- return distance;
13068
- }
13069
- /**
13070
- * Pixi's write of `uDistance`, at a layout: the CSS distance and, while the pipe names the text, the distance per unit
13071
- * of its group scale.
13072
- *
13073
- * @param this - The text's uniforms.
13074
- * @param value - Pixi's distance, CSS px.
13075
- */
13076
- function writeDistance(value) {
13077
- const state = this[DISTANCE];
13078
- const text = state.writing.text;
13079
- const scale = text === void 0 ? 0 : scaleOf$1(text.groupTransform);
13080
- state.cell[0] = value;
13081
- state.cell[1] = scale > 0 ? value / scale : 0;
13082
- state.text = text;
13083
- }
13084
- /**
13085
- * The `uDistance` of every text's uniforms: one pair of functions, so every uniforms object has the same hidden class
13086
- * and Pixi's generated sync function reads it monomorphic. A pair made per text, or an accessor laid over Pixi's data
13087
- * property, sends each object to dictionary mode. The getter hands a still text's answer back boxed once (`answer`):
13088
- * Pixi's sync may not inline it.
13089
- */
13090
- const DISTANCE_ACCESSOR = {
13091
- enumerable: true,
13092
- configurable: true,
13093
- get: readDistance,
13094
- set: writeDistance
13095
- };
12909
+ function cachedBy(make) {
12910
+ const shaders = /* @__PURE__ */ new Map();
12911
+ return (maxTextures) => {
12912
+ const known = shaders.get(maxTextures);
12913
+ if (known !== void 0) return known;
12914
+ const shader = make(maxTextures);
12915
+ shaders.set(maxTextures, shader);
12916
+ return shader;
12917
+ };
12918
+ }
13096
12919
  /**
13097
- * The local uniforms of one text, in the order of the WGSL struct, at Pixi's starting values. The
13098
- * pipe writes `uDistance`, the graphics adaptor the rest, before every draw.
12920
+ * The shaders of the batcher: Pixi's five SDF bits on both backends with the local uniforms replaced by the
12921
+ * renderer's piece, and Pixi's batch sampler slots for WebGL, as Pixi's own `DefaultShader` carries them.
13099
12922
  *
13100
- * Pixi writes `uDistance` in CSS pixels (`AbstractBitmapTextPipe._updateDistanceField`: the scale
13101
- * of the text's group transform times the font's range over its scale), while the shader measures
13102
- * the glyph edge in device pixels, so at a resolution above 1 the edge is smeared over that many
13103
- * more pixels. The group's `uDistance` is therefore an accessor: Pixi's write keeps the CSS value,
13104
- * every read answers it times `density.resolution`, and Pixi reads the uniform by property each
13105
- * time it uploads it, on both backends. The resolution is read at the draw, so a change of
13106
- * resolution needs nothing more.
13107
- *
13108
- * The scale is the text's world scale, read at the draw as well. Pixi's group transform is the
13109
- * text against its render group, which leaves out the group's own scale: a kept picture renders its
13110
- * view as a render group of its own, so its labels would draw with other edges than live. And Pixi
13111
- * writes the distance only when the group's instructions are built, so a label whose view scaled
13112
- * since would keep the edge of the old scale. A write of Pixi made while the pipe names the text
13113
- * keeps the distance per unit of the group scale it was made with; a read answers that times the
13114
- * text's world scale. A write without a text, or at a group scale of 0, is answered as Pixi wrote it.
13115
- *
13116
- * The uniforms object is the renderer's own, made in one shape for every text with the one accessor pair
13117
- * `DISTANCE_ACCESSOR`, in place of the one Pixi's group made.
12923
+ * @param pixi - The loaded Pixi module.
12924
+ * @returns The shader for a texture limit.
12925
+ */
12926
+ function sdfShaders(pixi) {
12927
+ return cachedBy((maxTextures) => new pixi.Shader({
12928
+ gpuProgram: pixi.compileHighShaderGpuProgram({
12929
+ name: PROGRAM_NAME,
12930
+ bits: [
12931
+ pixi.colorBit,
12932
+ pixi.generateTextureBatchBit(maxTextures),
12933
+ SDF_BIT,
12934
+ pixi.mSDFBit,
12935
+ pixi.roundPixelsBit
12936
+ ]
12937
+ }),
12938
+ glProgram: pixi.compileHighShaderGlProgram({
12939
+ name: PROGRAM_NAME,
12940
+ bits: [
12941
+ pixi.colorBitGl,
12942
+ pixi.generateTextureBatchBitGl(maxTextures),
12943
+ SDF_BIT_GL,
12944
+ pixi.mSDFBitGl,
12945
+ pixi.roundPixelsBitGl
12946
+ ]
12947
+ }),
12948
+ resources: { batchSamplers: pixi.getBatchSamplersUniformGroup(maxTextures) }
12949
+ }));
12950
+ }
12951
+ /**
12952
+ * The geometry of one batcher: Pixi's batch geometry with the distance after the texture id, one buffer of 7 floats a
12953
+ * vertex.
13118
12954
  *
13119
12955
  * @param pixi - The loaded Pixi module.
13120
- * @param density - What the device pixels per CSS pixel are read from at each draw: the renderer.
13121
- * @param density.resolution - The renderer's resolution, read at each upload.
13122
- * @param writing - The text whose distance Pixi writes now, held by the pipe.
13123
- * @returns A new uniform group.
13124
- */
13125
- function createLocalUniforms(pixi, density, writing) {
13126
- const group = new pixi.UniformGroup({
13127
- uColor: {
13128
- value: new Float32Array([
13129
- 1,
13130
- 1,
13131
- 1,
13132
- 1
13133
- ]),
13134
- type: "vec4<f32>"
13135
- },
13136
- uTransformMatrix: {
13137
- value: new pixi.Matrix(),
13138
- type: "mat3x3<f32>"
13139
- },
13140
- uDistance: {
13141
- value: 4,
13142
- type: "f32"
12956
+ * @returns A new geometry.
12957
+ */
12958
+ function sdfGeometry(pixi) {
12959
+ const buffer = new pixi.Buffer({
12960
+ data: /* @__PURE__ */ new Float32Array(1),
12961
+ label: "attribute-sdf-batch-buffer",
12962
+ usage: pixi.BufferUsage.VERTEX | pixi.BufferUsage.COPY_DST,
12963
+ shrinkToFit: false
12964
+ });
12965
+ const indexBuffer = new pixi.Buffer({
12966
+ data: /* @__PURE__ */ new Uint32Array(1),
12967
+ label: "index-sdf-batch-buffer",
12968
+ usage: pixi.BufferUsage.INDEX | pixi.BufferUsage.COPY_DST,
12969
+ shrinkToFit: false
12970
+ });
12971
+ return new pixi.Geometry({
12972
+ attributes: {
12973
+ aPosition: {
12974
+ buffer,
12975
+ format: "float32x2",
12976
+ stride: STRIDE,
12977
+ offset: 0
12978
+ },
12979
+ aUV: {
12980
+ buffer,
12981
+ format: "float32x2",
12982
+ stride: STRIDE,
12983
+ offset: 8
12984
+ },
12985
+ aColor: {
12986
+ buffer,
12987
+ format: "unorm8x4",
12988
+ stride: STRIDE,
12989
+ offset: 16
12990
+ },
12991
+ aTextureIdAndRound: {
12992
+ buffer,
12993
+ format: "uint16x2",
12994
+ stride: STRIDE,
12995
+ offset: 20
12996
+ },
12997
+ aSdfDistance: {
12998
+ buffer,
12999
+ format: "float32",
13000
+ stride: STRIDE,
13001
+ offset: 24
13002
+ }
13143
13003
  },
13144
- uRound: {
13145
- value: 0,
13146
- type: "f32"
13147
- }
13004
+ indexBuffer
13148
13005
  });
13149
- const values = group.uniforms;
13150
- const state = {
13151
- cell: Float64Array.of(4, 0, NaN),
13152
- text: void 0,
13153
- writing,
13154
- density,
13155
- answer: void 0
13156
- };
13157
- const uniforms = {
13158
- uColor: values.uColor,
13159
- uTransformMatrix: values.uTransformMatrix,
13160
- uRound: values.uRound
13161
- };
13162
- Object.defineProperty(uniforms, "uDistance", DISTANCE_ACCESSOR);
13163
- Object.defineProperty(uniforms, DISTANCE, { value: state });
13164
- group.uniforms = uniforms;
13165
- return group;
13166
13006
  }
13167
13007
  /**
13168
- * The shader a text already draws with on one renderer: the custom shader of its graphics context.
13008
+ * Builds the batcher of distance-field glyphs from the module object, at call time, because Pixi's `Batcher` arrives
13009
+ * with the lazily loaded module. Its packing is Pixi's `DefaultBatcher`'s loop with the distance written after the
13010
+ * texture id; the distances are packed at the resolution the batcher pipe last drew it at (`packAt`).
13169
13011
  *
13170
- * @param text - The text.
13171
- * @param renderer - The renderer's uid, the key of the text's GPU data.
13172
- * @returns The shader, or `undefined` before the text was laid out on that renderer.
13173
- */
13174
- function shaderOf(text, renderer) {
13175
- return text._gpuData[renderer]?.context.customShader ?? void 0;
13012
+ * @param pixi - The loaded Pixi module.
13013
+ * @returns The batcher class, with the extension Pixi's registry files it under.
13014
+ */
13015
+ function createSdfBatcher(pixi) {
13016
+ const shaderFor = sdfShaders(pixi);
13017
+ /** Pixi's batcher, with a vertex that carries the edge width of its glyph. */
13018
+ class SdfBatcher extends pixi.Batcher {
13019
+ static extension = {
13020
+ type: [pixi.ExtensionType.Batcher],
13021
+ name: SDF_BATCHER
13022
+ };
13023
+ name = SDF_BATCHER;
13024
+ geometry = sdfGeometry(pixi);
13025
+ shader = shaderFor(this.maxTextures);
13026
+ /** Device pixels per CSS pixel the distances are packed at; a double from the start, so writes box nothing. */
13027
+ density = NaN;
13028
+ vertexSize = VERTEX_SIZE;
13029
+ /**
13030
+ * Packs the vertices of a mesh element: Pixi's default vertex and the distance.
13031
+ *
13032
+ * @param element - The element.
13033
+ * @param float32View - The attribute buffer as floats.
13034
+ * @param uint32View - The attribute buffer as unsigned integers.
13035
+ * @param index - The first float of the element.
13036
+ * @param textureId - The element's slot in its batch's textures.
13037
+ */
13038
+ packAttributes(element, float32View, uint32View, index, textureId) {
13039
+ const textureIdAndRound = textureId << 16 | element.roundPixels & 65535;
13040
+ const { a, b, c, d, tx, ty } = element.transform;
13041
+ const distance = distanceOf(element, a, b, c, d, this.density);
13042
+ const positions = element.positions;
13043
+ const uvs = element.uvs;
13044
+ const argb = element.color;
13045
+ const end = element.attributeOffset + element.attributeSize;
13046
+ let at = index;
13047
+ for (let vertex = element.attributeOffset; vertex < end; vertex += 1) {
13048
+ const x = positions[vertex * 2] ?? 0;
13049
+ const y = positions[vertex * 2 + 1] ?? 0;
13050
+ float32View[at] = a * x + c * y + tx;
13051
+ float32View[at + 1] = d * y + b * x + ty;
13052
+ float32View[at + 2] = uvs[vertex * 2] ?? 0;
13053
+ float32View[at + 3] = uvs[vertex * 2 + 1] ?? 0;
13054
+ uint32View[at + 4] = argb;
13055
+ uint32View[at + 5] = textureIdAndRound;
13056
+ float32View[at + 6] = distance;
13057
+ at += VERTEX_SIZE;
13058
+ }
13059
+ }
13060
+ /**
13061
+ * Packs the four vertices of a quad element: Pixi's default quad and the distance.
13062
+ *
13063
+ * @param element - The element.
13064
+ * @param float32View - The attribute buffer as floats.
13065
+ * @param uint32View - The attribute buffer as unsigned integers.
13066
+ * @param index - The first float of the element.
13067
+ * @param textureId - The element's slot in its batch's textures.
13068
+ */
13069
+ packQuadAttributes(element, float32View, uint32View, index, textureId) {
13070
+ const { a, b, c, d, tx, ty } = element.transform;
13071
+ const { minX, minY, maxX, maxY } = element.bounds;
13072
+ const uvs = element.texture.uvs;
13073
+ const argb = element.color;
13074
+ const textureIdAndRound = textureId << 16 | element.roundPixels & 65535;
13075
+ const distance = distanceOf(element, a, b, c, d, this.density);
13076
+ float32View[index] = a * minX + c * minY + tx;
13077
+ float32View[index + 1] = d * minY + b * minX + ty;
13078
+ float32View[index + 2] = uvs.x0;
13079
+ float32View[index + 3] = uvs.y0;
13080
+ uint32View[index + 4] = argb;
13081
+ uint32View[index + 5] = textureIdAndRound;
13082
+ float32View[index + 6] = distance;
13083
+ float32View[index + 7] = a * maxX + c * minY + tx;
13084
+ float32View[index + 8] = d * minY + b * maxX + ty;
13085
+ float32View[index + 9] = uvs.x1;
13086
+ float32View[index + 10] = uvs.y1;
13087
+ uint32View[index + 11] = argb;
13088
+ uint32View[index + 12] = textureIdAndRound;
13089
+ float32View[index + 13] = distance;
13090
+ float32View[index + 14] = a * maxX + c * maxY + tx;
13091
+ float32View[index + 15] = d * maxY + b * maxX + ty;
13092
+ float32View[index + 16] = uvs.x2;
13093
+ float32View[index + 17] = uvs.y2;
13094
+ uint32View[index + 18] = argb;
13095
+ uint32View[index + 19] = textureIdAndRound;
13096
+ float32View[index + 20] = distance;
13097
+ float32View[index + 21] = a * minX + c * maxY + tx;
13098
+ float32View[index + 22] = d * maxY + b * minX + ty;
13099
+ float32View[index + 23] = uvs.x3;
13100
+ float32View[index + 24] = uvs.y3;
13101
+ uint32View[index + 25] = argb;
13102
+ uint32View[index + 26] = textureIdAndRound;
13103
+ float32View[index + 27] = distance;
13104
+ }
13105
+ /**
13106
+ * Packs every element of the drawn batches again at a resolution and hands the buffer to the GPU.
13107
+ *
13108
+ * @param density - Device pixels per CSS pixel.
13109
+ */
13110
+ packAt(density) {
13111
+ this.density = density;
13112
+ for (let batch = 0; batch < this.batchIndex; batch += 1) {
13113
+ const elements = this.batches[batch]?.elements;
13114
+ if (elements === void 0) continue;
13115
+ for (let at = 0; at < elements.length; at += 1) {
13116
+ const element = elements[at];
13117
+ if (element !== void 0) this.updateElement(element);
13118
+ }
13119
+ }
13120
+ this.dirty = false;
13121
+ this.geometry.buffers[0]?.update(this.attributeSize * 4);
13122
+ }
13123
+ }
13124
+ return SdfBatcher;
13176
13125
  }
13177
13126
  /**
13178
- * Keeps one compiled program per batch texture limit: compiled the first time a limit is asked
13179
- * for, shared by every text after. Its own function, because lint rule L5 refuses a collection
13180
- * built inside an exported declaration.
13127
+ * Tells whether a batch's batcher is the renderer's batcher of distance-field glyphs.
13181
13128
  *
13182
- * @param compile - Compiles the program of one texture limit.
13183
- * @returns The program for a texture limit.
13129
+ * @param batcher - The batcher of a batch.
13130
+ * @returns True for the SDF batcher.
13184
13131
  */
13185
- function cachedBy(compile) {
13186
- const programs = /* @__PURE__ */ new Map();
13187
- return (maxTextures) => {
13188
- const known = programs.get(maxTextures);
13189
- if (known !== void 0) return known;
13190
- const program = compile(maxTextures);
13191
- programs.set(maxTextures, program);
13192
- return program;
13193
- };
13132
+ function isSdfBatcher(batcher) {
13133
+ return batcher.name === SDF_BATCHER;
13194
13134
  }
13195
13135
  /**
13196
- * The compile step of the renderer's WGSL SDF program: Pixi's `SdfShader` bits with the fragment
13197
- * step of the local-uniform piece replaced.
13136
+ * Builds the renderer's batcher pipe from the module object, at call time. It keeps the base's `static extension`
13137
+ * object, so the registry files it under the same types and name, and Pixi's batcher registry, which `getBatcher`
13138
+ * reads. `addToBatch` names the batcher of an element before Pixi adds it: the glyphs of a distance-field text go to
13139
+ * `SDF_BATCHER`; a pooled element that carried that name before, now another graphics' or a plain font's, goes back to
13140
+ * Pixi's default. `execute` packs the distances of a batcher again when the renderer's resolution changed since it
13141
+ * last drew, before Pixi binds its geometry. The monitor's counting pipe of a dev build extends this pipe.
13198
13142
  *
13199
13143
  * @param pixi - The loaded Pixi module.
13200
- * @returns The WebGPU program for a texture limit.
13144
+ * @returns The pipe class.
13201
13145
  */
13202
- function gpuProgramCache(pixi) {
13203
- const base = pixi.localUniformMSDFBit;
13204
- const textBit = {
13205
- ...base,
13206
- name: TEXT_BIT_NAME,
13207
- fragment: {
13208
- ...base.fragment,
13209
- main: SINGLE_ALPHA_STEP
13210
- }
13211
- };
13212
- return cachedBy((maxTextures) => pixi.compileHighShaderGpuProgram({
13213
- name: PROGRAM_NAME,
13214
- bits: [
13215
- pixi.colorBit,
13216
- pixi.generateTextureBatchBit(maxTextures),
13217
- textBit,
13218
- pixi.mSDFBit,
13219
- pixi.roundPixelsBit
13220
- ]
13221
- }));
13146
+ function createSdfBatcherPipe(pixi) {
13147
+ /** Pixi's batcher pipe, which routes the glyphs of distance-field text to the renderer's batcher. */
13148
+ class SdfRoutingBatcherPipe extends pixi.BatcherPipe {
13149
+ static extension = pixi.BatcherPipe.extension;
13150
+ /**
13151
+ * Names the element's batcher, then adds it as Pixi does.
13152
+ *
13153
+ * @param element - The element.
13154
+ * @param instructionSet - The instructions of its render group.
13155
+ */
13156
+ addToBatch(element, instructionSet) {
13157
+ routeElement(element);
13158
+ super.addToBatch(element, instructionSet);
13159
+ }
13160
+ /**
13161
+ * Draws a batch as Pixi does; the first batch of an SDF batcher's run first packs the distances again when the
13162
+ * resolution moved.
13163
+ *
13164
+ * @param batch - The batch.
13165
+ */
13166
+ execute(batch) {
13167
+ const batcher = batch.batcher;
13168
+ if (batch.action === "startBatch" && isSdfBatcher(batcher)) {
13169
+ const resolution = this.renderer.resolution;
13170
+ if (batcher.density !== resolution) batcher.packAt(resolution);
13171
+ }
13172
+ super.execute(batch);
13173
+ }
13174
+ }
13175
+ return SdfRoutingBatcherPipe;
13222
13176
  }
13223
13177
  /**
13224
- * The compile step of the renderer's GLSL SDF program: Pixi's `SdfShader` GL bits with the same
13225
- * fragment step replaced.
13178
+ * Names the batcher of one element: `SDF_BATCHER` for a glyph of a distance-field text, Pixi's default again for an
13179
+ * element that carried that name from an earlier owner, as it was otherwise.
13226
13180
  *
13227
- * @param pixi - The loaded Pixi module.
13228
- * @returns The WebGL program for a texture limit.
13181
+ * @param element - The element.
13229
13182
  */
13230
- function glProgramCache(pixi) {
13231
- const base = pixi.localUniformMSDFBitGl;
13232
- const textBit = {
13233
- ...base,
13234
- name: TEXT_BIT_NAME,
13235
- fragment: {
13236
- ...base.fragment,
13237
- main: SINGLE_ALPHA_STEP_GL
13238
- }
13239
- };
13240
- return cachedBy((maxTextures) => pixi.compileHighShaderGlProgram({
13241
- name: PROGRAM_NAME,
13242
- bits: [
13243
- pixi.colorBitGl,
13244
- pixi.generateTextureBatchBitGl(maxTextures),
13245
- textBit,
13246
- pixi.mSDFBitGl,
13247
- pixi.roundPixelsBitGl
13248
- ]
13249
- }));
13183
+ function routeElement(element) {
13184
+ const glyphs = element.renderable?.[SDF];
13185
+ if (glyphs !== void 0 && glyphs.perScale > 0) element.batcherName = SDF_BATCHER;
13186
+ else if (element.batcherName === "moku-sdf") element.batcherName = DEFAULT_BATCHER;
13250
13187
  }
13188
+ //#endregion
13189
+ //#region src/plugins/renderer/host/sdf-text.ts
13190
+ /** The suffix of a bitmap font's key in Pixi's cache, the one `_updateDistanceField` reads. */
13191
+ const CACHE_SUFFIX$1 = "-bitmap";
13251
13192
  /**
13252
- * Builds the renderer's bitmap text pipe from the module object. It is made at call time, because
13253
- * Pixi's pipe arrives with the lazily loaded module. It keeps the base's `static extension` object
13254
- * as it is, so the registry files it under the same types and name. Every text gets a shader of its
13255
- * own once, at its first layout, over the one shared program of its backend, and keeps it when it is laid out again
13256
- * (Pixi asks for one at every layout); a WebGL shader also gets
13257
- * Pixi's batch sampler slots, as Pixi's own `SdfShader` does. Its `uDistance` answers Pixi's CSS
13258
- * distance at the text's world scale times the renderer's resolution: the pipe names the text
13259
- * while Pixi's `addRenderable` and `updateRenderable`, the two that write the distance, run.
13193
+ * Measures the edge of a text's glyphs again when its font family or size changed since: Pixi's distance at scale 1
13194
+ * from the font in Pixi's cache, 0 for a font without a distance field or not in the cache.
13195
+ *
13196
+ * @param pixi - The loaded Pixi module.
13197
+ * @param glyphs - The text's glyph record.
13198
+ * @param style - The text's style.
13199
+ */
13200
+ function measureGlyphs(pixi, glyphs, style) {
13201
+ const family = style.fontFamily;
13202
+ const size = style.fontSize;
13203
+ if (family === glyphs.family && size === glyphs.size) return;
13204
+ const key = `${String(family)}${CACHE_SUFFIX$1}`;
13205
+ const font = pixi.Cache.has(key) ? pixi.Cache.get(key) : void 0;
13206
+ const field = font?.distanceField;
13207
+ glyphs.family = family;
13208
+ glyphs.size = size;
13209
+ glyphs.perScale = font === void 0 || field === void 0 || field.type === "none" ? 0 : field.range * (1 / (font.baseRenderedFontSize / size));
13210
+ }
13211
+ /**
13212
+ * Builds the renderer's bitmap text pipe from the module object, at call time, because Pixi's pipe arrives with the
13213
+ * lazily loaded module. It keeps the base's `static extension` object, so the registry files it under the same types
13214
+ * and name. Every text's proxy graphics gets a batchable context and its glyph record when Pixi makes it; the record is
13215
+ * brought up to date before Pixi adds or updates the text, so the batcher packs the edge of the text's font.
13260
13216
  *
13261
13217
  * @param pixi - The loaded Pixi module.
13262
13218
  * @returns The pipe class.
13263
13219
  */
13264
13220
  function createSdfTextPipe(pixi) {
13265
- const gpuProgramFor = gpuProgramCache(pixi);
13266
- const glProgramFor = glProgramCache(pixi);
13267
- /** Pixi's bitmap text pipe, with an SDF shader that applies the alpha once. */
13268
- class SingleAlphaBitmapTextPipe extends pixi.BitmapTextPipe {
13221
+ /** Pixi's bitmap text pipe, with distance-field text drawn by the renderer's batcher. */
13222
+ class SdfTextPipe extends pixi.BitmapTextPipe {
13269
13223
  static extension = pixi.BitmapTextPipe.extension;
13270
- /** The text whose distance Pixi writes now, read by the uniforms of every text of this pipe. */
13271
- writing = { text: void 0 };
13272
13224
  /**
13273
- * The text Pixi lays out now: `getSdfShader` hands its own shader back, read at the call, so the text keeps it and no
13274
- * shader is made. Pixi asks `getSdfShader()` at every layout of a text (twice for a new one) and keeps the answer
13275
- * only for a context without a shader: a new shader each time was garbage (about 22 KB a text update), and on WebGL
13276
- * it listened to the shared batch-sampler group for good, a leak.
13225
+ * Makes a text's proxy graphics as Pixi does, batchable at any length, with the edge of its glyphs.
13226
+ *
13227
+ * @param text - The text.
13228
+ * @returns The proxy.
13277
13229
  */
13278
- laying;
13230
+ initGpuText(text) {
13231
+ const proxy = super.initGpuText(text);
13232
+ const glyphs = {
13233
+ perScale: NaN,
13234
+ family: void 0,
13235
+ size: NaN
13236
+ };
13237
+ proxy.context.batchMode = "batch";
13238
+ proxy[SDF] = glyphs;
13239
+ measureGlyphs(pixi, glyphs, text._style);
13240
+ return proxy;
13241
+ }
13279
13242
  /**
13280
- * Adds a text to the instructions as Pixi does, naming it while Pixi writes its distance and lays it out.
13243
+ * Adds a text to the instructions as Pixi does, its glyph edge measured first.
13281
13244
  *
13282
13245
  * @param text - The text.
13283
13246
  * @param instructionSet - The instructions of its render group.
13284
13247
  */
13285
13248
  addRenderable(text, instructionSet) {
13286
- this.writing.text = text;
13287
- this.laying = text;
13249
+ this.measure(text);
13288
13250
  super.addRenderable(text, instructionSet);
13289
- this.laying = void 0;
13290
- this.writing.text = void 0;
13291
13251
  }
13292
13252
  /**
13293
- * Updates a text as Pixi does, naming it while Pixi writes its distance.
13253
+ * Updates a text as Pixi does, its glyph edge measured first.
13294
13254
  *
13295
13255
  * @param text - The text.
13296
13256
  */
13297
13257
  updateRenderable(text) {
13298
- this.writing.text = text;
13258
+ this.measure(text);
13299
13259
  super.updateRenderable(text);
13300
- this.writing.text = void 0;
13301
13260
  }
13302
13261
  /**
13303
- * The shader of one distance-field text, asked for each time Pixi lays the text out: the text's own when it has one,
13304
- * else a new one.
13262
+ * No shader: Pixi then leaves the text's context without one, and batchable.
13305
13263
  *
13306
- * @returns The text's shader, or a new shader with its own local uniforms on the program of the backend.
13264
+ * @returns `null`.
13307
13265
  */
13308
13266
  getSdfShader() {
13309
- const own = this.laying === void 0 ? void 0 : shaderOf(this.laying, this._renderer.uid);
13310
- if (own !== void 0) return own;
13311
- const maxTextures = this._renderer.limits.maxBatchableTextures;
13312
- const density = this._renderer;
13313
- if (this._renderer.name === "webgpu") return new pixi.Shader({
13314
- gpuProgram: gpuProgramFor(maxTextures),
13315
- resources: { localUniforms: createLocalUniforms(pixi, density, this.writing) }
13316
- });
13317
- return new pixi.Shader({
13318
- glProgram: glProgramFor(maxTextures),
13319
- resources: {
13320
- localUniforms: createLocalUniforms(pixi, density, this.writing),
13321
- batchSamplers: pixi.getBatchSamplersUniformGroup(maxTextures)
13322
- }
13323
- });
13267
+ return null;
13268
+ }
13269
+ /**
13270
+ * Measures the glyph edge of a text Pixi made a proxy for already; a new text's proxy measures its own.
13271
+ *
13272
+ * @param text - The text.
13273
+ */
13274
+ measure(text) {
13275
+ const proxy = text._gpuData[this._renderer.uid];
13276
+ if (proxy !== void 0) measureGlyphs(pixi, proxy[SDF], text._style);
13324
13277
  }
13325
13278
  }
13326
- return SingleAlphaBitmapTextPipe;
13279
+ return SdfTextPipe;
13327
13280
  }
13328
13281
  /**
13329
- * Swaps Pixi's bitmap text pipe for the renderer's in the module's extension registry: `remove`
13330
- * frees the name on both backends, `add` puts the subclass in under it. Every application made
13331
- * afterwards draws its distance-field text with it. The registry is global to the module object,
13332
- * so the remover matters: it swaps Pixi's own pipe back.
13282
+ * Installs the three pieces of batched distance-field text in the module's extension registry: the batcher under its
13283
+ * own name, and the bitmap text pipe and the batch pipe under Pixi's, on both backends. Every application made
13284
+ * afterwards draws distance-field text with them. Every class is built before the registry is touched, so a module
13285
+ * without the pieces throws with the registry as it was. The registry is global to the module object, so the remover
13286
+ * matters: it swaps Pixi's own pipes back and leaves the batcher, which a pooled element may still name. A counting batch pipe of the monitor, which extends the routing pipe, is
13287
+ * installed after this one, in its place.
13333
13288
  *
13334
13289
  * @param pixi - The loaded Pixi module.
13335
13290
  * @returns The remover.
13336
- * @throws {TypeError} When the module has no `BitmapTextPipe` to subclass.
13337
- */
13338
- function installSdfTextPipe(pixi) {
13339
- const pipe = createSdfTextPipe(pixi);
13340
- pixi.extensions.remove(pixi.BitmapTextPipe);
13341
- pixi.extensions.add(pipe);
13291
+ * @throws {TypeError} When the module has no `Batcher`, `BitmapTextPipe` or `BatcherPipe` to subclass.
13292
+ */
13293
+ function installSdfText(pixi) {
13294
+ const batcher = createSdfBatcher(pixi);
13295
+ const textPipe = createSdfTextPipe(pixi);
13296
+ const batchPipe = createSdfBatcherPipe(pixi);
13297
+ pixi.extensions.remove(pixi.BitmapTextPipe, pixi.BatcherPipe);
13298
+ pixi.extensions.add(batcher, textPipe, batchPipe);
13342
13299
  return () => {
13343
- pixi.extensions.remove(pipe);
13344
- pixi.extensions.add(pixi.BitmapTextPipe);
13300
+ pixi.extensions.remove(textPipe, batchPipe);
13301
+ pixi.extensions.add(pixi.BitmapTextPipe, pixi.BatcherPipe);
13345
13302
  };
13346
13303
  }
13347
13304
  //#endregion
@@ -13478,26 +13435,27 @@ function installCounting$1(ctx, pixi, deps) {
13478
13435
  } else installPasses(ctx, pixi, deps.installPasses);
13479
13436
  }
13480
13437
  /**
13481
- * Swaps Pixi's bitmap text pipe for the renderer's, whose SDF shader applies the alpha of
13482
- * distance-field text once. Like the draw counter it runs before `new Application()`, because Pixi
13483
- * builds its pipes inside `init`, and once per host state: a restore reuses the installed pipe.
13484
- * Every build installs it. A Pixi without the pieces draws on with its own pipe, with a warning.
13438
+ * Installs the renderer's batched distance-field text: its batcher, its bitmap text pipe and its batch pipe, which draw
13439
+ * consecutive labels in one draw with the alpha applied once. It runs before `new Application()`, because Pixi builds
13440
+ * its pipes inside `init`, and before the draw counter, whose batch pipe extends the routing one and takes its place;
13441
+ * once per host state: a restore reuses the installed pieces. Every build installs it. A Pixi without the pieces draws
13442
+ * on with its own pipes, with a warning.
13485
13443
  *
13486
13444
  * @param ctx - Domain context of the renderer plugin.
13487
13445
  * @param pixi - The module the application is built from.
13488
13446
  */
13489
- function installSdfText(ctx, pixi) {
13447
+ function installTextBatch(ctx, pixi) {
13490
13448
  const state = ctx.state.host;
13491
13449
  if (state.uninstallSdfText !== void 0) return;
13492
13450
  try {
13493
- state.uninstallSdfText = installSdfTextPipe(pixi);
13451
+ state.uninstallSdfText = installSdfText(pixi);
13494
13452
  } catch (error) {
13495
13453
  ctx.log.warn("renderer: distance-field text applies its alpha twice", { error });
13496
13454
  }
13497
13455
  }
13498
13456
  /**
13499
13457
  * Swaps Pixi's render-group pipe for the renderer's, which binds a filter's input again after a group cached as a
13500
- * texture was refreshed inside the filter (F10). Like the bitmap text pipe it runs before `new Application()`, once per
13458
+ * texture was refreshed inside the filter (F10). Like the batched text it runs before `new Application()`, once per
13501
13459
  * host state, in every build. A Pixi without the pieces draws on with its own pipe, with a warning.
13502
13460
  *
13503
13461
  * @param ctx - Domain context of the renderer plugin.
@@ -13531,8 +13489,8 @@ async function createApplication(ctx, mount, deps) {
13531
13489
  const pixi = loaded ?? await ctx.config.loadPixi();
13532
13490
  if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__ && loaded === void 0) startupMark("moku:pixi-end");
13533
13491
  state.pixi = pixi;
13492
+ installTextBatch(ctx, pixi);
13534
13493
  installCounting$1(ctx, pixi, deps);
13535
- installSdfText(ctx, pixi);
13536
13494
  installRenderGroup(ctx, pixi);
13537
13495
  state.resolution.dpr = devicePixels();
13538
13496
  state.resolution.cap = ctx.config.maxResolution;
@@ -13577,7 +13535,7 @@ function destroyApplication(state) {
13577
13535
  /**
13578
13536
  * Undoes everything `init` created. Works on the state alone, because `onStop` has no context.
13579
13537
  * `ready` drops first, so a device that goes away during the teardown is ignored. The draw
13580
- * counter, the bitmap text pipe and the render-group pipe swap Pixi's own classes back, so the next application on
13538
+ * counter, the batched text and the render-group pipe swap Pixi's own classes back, so the next application on
13581
13539
  * the page starts from them. The pool trim starts over and its listeners go; Pixi's pool itself is left.
13582
13540
  *
13583
13541
  * @param state - The host branch of the plugin state.
@@ -26837,19 +26795,22 @@ function countUpload(sinks, source) {
26837
26795
  /**
26838
26796
  * Builds the seven counting subclasses from the module object. They are made at call time,
26839
26797
  * because the base classes arrive with the lazily loaded module. Each keeps the base's `static
26840
- * extension` object as it is, so the registry files it under the same type and name.
26798
+ * extension` object as it is, so the registry files it under the same type and name. The batch pipe extends the one
26799
+ * it is given: the routing pipe of the renderer's batched text, which the plugin root injects, so the counting pipe
26800
+ * that takes its place routes the same.
26841
26801
  *
26842
26802
  * @param pixi - The loaded Pixi module.
26843
26803
  * @param sinks - What every instance counts into.
26804
+ * @param batcherPipe - The batch pipe the counting one extends; Pixi's own when none is given.
26844
26805
  * @returns The seven counting classes (BatcherPipe among them).
26845
26806
  */
26846
- function createCountingClasses(pixi, sinks) {
26807
+ function createCountingClasses(pixi, sinks, batcherPipe = pixi.BatcherPipe) {
26847
26808
  const counter = sinks.draws;
26848
26809
  /**
26849
26810
  * Counts every rebuild of a render group's instruction list: Pixi's render group system calls `buildStart` once per
26850
26811
  * rebuild and nowhere else. It inherits the static batcher registry, so batchers resolve as before.
26851
26812
  */
26852
- class CountingBatcherPipe extends pixi.BatcherPipe {
26813
+ class CountingBatcherPipe extends batcherPipe {
26853
26814
  static extension = pixi.BatcherPipe.extension;
26854
26815
  /**
26855
26816
  * Counts the rebuild, in the frame's counter only inside a frame, then begins it.
@@ -27056,10 +27017,11 @@ function createCountingClasses(pixi, sinks) {
27056
27017
  *
27057
27018
  * @param pixi - The loaded Pixi module.
27058
27019
  * @param sinks - The sinks of the renderer that installs it.
27020
+ * @param batcherPipe - The batch pipe the counting one extends: the routing pipe of the batched text.
27059
27021
  * @returns The remover.
27060
27022
  */
27061
- function installCounting(pixi, sinks) {
27062
- const counting = createCountingClasses(pixi, sinks);
27023
+ function installCounting(pixi, sinks, batcherPipe = pixi.BatcherPipe) {
27024
+ const counting = createCountingClasses(pixi, sinks, batcherPipe);
27063
27025
  const bases = [
27064
27026
  pixi.BatcherPipe,
27065
27027
  pixi.GpuBatchAdaptor,
@@ -29427,14 +29389,14 @@ function createViewportApi(ctx, deps) {
29427
29389
  /**
29428
29390
  * The counting `host` installs before `new Application()`: the seven counting classes (BatcherPipe among them) of
29429
29391
  * `monitor` in a dev build and in a debug build, bound to the sinks in the monitor branch of the
29430
- * state, none in production. A production `define` folds the dev or agent guard, so the counting
29431
- * module leaves the bundle.
29392
+ * state, none in production; the counting batch pipe extends the routing pipe of the batched text. A production
29393
+ * `define` folds the dev or agent guard, so the counting module leaves the bundle.
29432
29394
  *
29433
29395
  * @param ctx - Domain context of the renderer plugin.
29434
29396
  * @returns The bound installer, or `undefined` without the dev flag and the agent flag.
29435
29397
  */
29436
29398
  function countingOf(ctx) {
29437
- return typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ || typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__ ? (pixi) => installCounting(pixi, ctx.state.monitor.sinks) : void 0;
29399
+ return typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ || typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__ ? (pixi) => installCounting(pixi, ctx.state.monitor.sinks, createSdfBatcherPipe(pixi)) : void 0;
29438
29400
  }
29439
29401
  /**
29440
29402
  * The writers of `sync` the kept cache draws through: the one owner of `renderable`, the one writer of a view's
@@ -35770,24 +35732,9 @@ function overTargetOf(ctx, node, run) {
35770
35732
  return takePending(ctx);
35771
35733
  }
35772
35734
  /**
35773
- * Holds the feature that declares the scene now mounted, then releases the hold of the scene
35774
- * before: the new hold first, so no phase sees the moment with neither. A scene no feature
35775
- * declared holds nothing.
35776
- *
35777
- * @param ctx - Domain context of the plugin.
35778
- * @param scene - The id of the scene now mounted.
35779
- */
35780
- function holdOwner(ctx, scene) {
35781
- const state = ctx.state;
35782
- const release = state.held;
35783
- const owner = state.owners.get(scene);
35784
- state.held = owner === void 0 ? void 0 : ctx.deps.world.ecs.hold(owner);
35785
- release?.();
35786
- }
35787
- /**
35788
35735
  * Builds the scene in one synchronous block: the layers, then the projections of the old scene
35789
- * out, then the ones of the new scene in, the hold on its feature, then the list the scene names
35790
- * to `assets`, then the frame loop out of its idle cap, and only then the event.
35736
+ * out, then the ones of the new scene in, the new scene to the world's systems, then the list the
35737
+ * scene names to `assets`, then the frame loop out of its idle cap, and only then the event.
35791
35738
  *
35792
35739
  * @param ctx - Domain context of the plugin.
35793
35740
  * @param scene - The scene whose bundles are there.
@@ -35802,7 +35749,7 @@ function apply(ctx, scene, named) {
35802
35749
  if (previous !== void 0) projection.unmount(previous.projections);
35803
35750
  projection.mount(scene.projections, state.owner);
35804
35751
  state.current = scene.id;
35805
- holdOwner(ctx, scene.id);
35752
+ ctx.deps.world.ecs.setScene(scene.id);
35806
35753
  state.named = named;
35807
35754
  ctx.deps.assets.sceneBundles(named);
35808
35755
  ctx.deps.time.wake();
@@ -35916,16 +35863,22 @@ function withDeps$5(ctx) {
35916
35863
  };
35917
35864
  }
35918
35865
  /**
35919
- * Reads the `scenes` key of every registered feature into the registry, and which feature declared
35920
- * each scene: a duplicate id names both, and the switch holds the declaring feature while its
35921
- * scene is mounted. Features register in their own `onInit`, which runs before this `onStart`, so
35922
- * every scene of the game is here.
35866
+ * Records which feature declared which scene, so a duplicate id can name both of them.
35867
+ *
35868
+ * @returns An empty table of scene id to feature name.
35869
+ */
35870
+ function emptyOwners$1() {
35871
+ return /* @__PURE__ */ new Map();
35872
+ }
35873
+ /**
35874
+ * Reads the `scenes` key of every registered feature into the registry. Features register in
35875
+ * their own `onInit`, which runs before this `onStart`, so every scene of the game is here.
35923
35876
  *
35924
35877
  * @param ctx - Domain context of the plugin.
35925
35878
  * @throws {Error} When two features declare the same scene id.
35926
35879
  */
35927
35880
  function readScenes(ctx) {
35928
- const owners = ctx.state.owners;
35881
+ const owners = emptyOwners$1();
35929
35882
  for (const feature of ctx.deps.flow.features.all()) {
35930
35883
  const list = feature.description.scenes;
35931
35884
  if (!Array.isArray(list)) continue;
@@ -35956,9 +35909,9 @@ function startScenes(ctx) {
35956
35909
  scenes.state.teardown = scenes.deps.flow.onEnter("scene", (node, run) => enterScene(scenes, node, run));
35957
35910
  }
35958
35911
  /**
35959
- * Removes the `onEnter` callback in `onStop` and forgets every scene, the bundles the mounted
35960
- * one named and the hold on its feature. Nothing is unmounted and nothing is released: a teardown
35961
- * context may not call another plugin, and `world.onStop` clears every entity and its scope anyway.
35912
+ * Removes the `onEnter` callback in `onStop` and forgets every scene and the bundles the mounted
35913
+ * one named. Nothing is unmounted: a
35914
+ * teardown context may not call another plugin, and `world.onStop` clears every entity anyway.
35962
35915
  *
35963
35916
  * @param teardown - What the teardown context carries.
35964
35917
  * @param teardown.state - The plugin state.
@@ -35973,8 +35926,6 @@ function stopScenes(teardown) {
35973
35926
  state.teardown?.();
35974
35927
  state.teardown = void 0;
35975
35928
  state.scenes.clear();
35976
- state.owners.clear();
35977
- state.held = void 0;
35978
35929
  state.current = void 0;
35979
35930
  state.pending = void 0;
35980
35931
  state.named = [];
@@ -35991,15 +35942,6 @@ function emptyRegistry$1() {
35991
35942
  return /* @__PURE__ */ new Map();
35992
35943
  }
35993
35944
  /**
35994
- * Creates the table of which feature declared which scene, for the same reason as
35995
- * `emptyRegistry`.
35996
- *
35997
- * @returns An empty table of scene id to feature name.
35998
- */
35999
- function emptyOwners$1() {
36000
- return /* @__PURE__ */ new Map();
36001
- }
36002
- /**
36003
35945
  * Creates the one owner every mount of this plugin carries. One is enough: a single scene is
36004
35946
  * mounted at a time and `unmount` is what flushes.
36005
35947
  *
@@ -36012,8 +35954,8 @@ function sceneOwner() {
36012
35954
  };
36013
35955
  }
36014
35956
  /**
36015
- * Creates the initial scenes state: nothing declared, nothing mounted, nothing pending, nothing
36016
- * held. The registry and its owners are filled in `onStart` from the feature descriptions.
35957
+ * Creates the initial scenes state: nothing declared, nothing mounted, nothing pending. The
35958
+ * registry is filled in `onStart` from the feature descriptions.
36017
35959
  *
36018
35960
  * @returns A fresh state, owned by one app.
36019
35961
  */
@@ -36024,9 +35966,7 @@ function createScenesState() {
36024
35966
  pending: void 0,
36025
35967
  named: [],
36026
35968
  owner: sceneOwner(),
36027
- teardown: void 0,
36028
- owners: emptyOwners$1(),
36029
- held: void 0
35969
+ teardown: void 0
36030
35970
  };
36031
35971
  }
36032
35972
  //#endregion
@@ -42258,8 +42198,9 @@ function countParticles(ectx) {
42258
42198
  state.over.particles = above;
42259
42199
  }
42260
42200
  /**
42261
- * Builds the `animate` system of the particles. It returns at once while the renderer does not
42262
- * draw. Time is the frame's `delta` only, so `time.step(dt)` drives every test.
42201
+ * Builds the `animate` system of the emitters. It runs while an entity carries `Emitter` (the world
42202
+ * skips a system whose query has no entity) and returns at once while the renderer does not draw.
42203
+ * Time is the frame's `delta` only, so `time.step(dt)` drives every test.
42263
42204
  *
42264
42205
  * @param ectx - Domain context of the effects plugin.
42265
42206
  * @returns The system `world.ecs.system` takes.
@@ -42275,6 +42216,26 @@ function createParticleSystem(ectx) {
42275
42216
  if (pixi === void 0) return;
42276
42217
  const reducedMotion = ectx.deps.anim.reducedMotion();
42277
42218
  for (const [entity, emitter] of rows) runEmitter(ectx, pixi, entity, emitter, context.time, reducedMotion);
42219
+ }
42220
+ });
42221
+ }
42222
+ /**
42223
+ * Builds the `animate` system of the orphans, registered right after the emitters: the particles
42224
+ * of an emitter whose host left fly on after the last `Emitter` is gone, so this system has no
42225
+ * query and runs every frame. It returns at once while the renderer does not draw, then steps the
42226
+ * orphans and keeps the particle budget.
42227
+ *
42228
+ * @param ectx - Domain context of the effects plugin.
42229
+ * @returns The system `world.ecs.system` takes.
42230
+ */
42231
+ function createOrphanSystem(ectx) {
42232
+ return system({
42233
+ name: "effects:orphans",
42234
+ phase: "animate",
42235
+ query: [],
42236
+ run: (_rows, context) => {
42237
+ const host = ectx.deps.renderer.host;
42238
+ if (!host.ready() || host.pixi() === void 0) return;
42278
42239
  runOrphans(ectx, context.time);
42279
42240
  countParticles(ectx);
42280
42241
  }
@@ -42602,7 +42563,7 @@ function warnScreenReads(ectx, entries) {
42602
42563
  });
42603
42564
  }
42604
42565
  /**
42605
- * Opens the world side: the two systems, the hook that retires an emitter's instance, and the two
42566
+ * Opens the world side: the three systems, the hook that retires an emitter's instance, and the two
42606
42567
  * hooks per filter kind; then records the filter components that are on entities already.
42607
42568
  *
42608
42569
  * @param ectx - Domain context of the effects plugin.
@@ -42610,7 +42571,7 @@ function warnScreenReads(ectx, entries) {
42610
42571
  */
42611
42572
  function openWorld(ectx, kinds) {
42612
42573
  const ecs = ectx.deps.world.ecs;
42613
- ectx.state.removers.push(ecs.system(createParticleSystem(ectx)), ecs.system(createFilterSystem(ectx)), ecs.onRemoved(Emitter, (entity) => forgetEmitter(ectx, entity)), ...kinds.map((kind) => ecs.onRemoved(kind.component, (entity) => forgetFilter(ectx, kind, entity))), ...kinds.map((kind) => ecs.onAdded(kind.component, (entity) => trackFilter(ectx, kind, entity))));
42574
+ ectx.state.removers.push(ecs.system(createParticleSystem(ectx)), ecs.system(createOrphanSystem(ectx)), ecs.system(createFilterSystem(ectx)), ecs.onRemoved(Emitter, (entity) => forgetEmitter(ectx, entity)), ...kinds.map((kind) => ecs.onRemoved(kind.component, (entity) => forgetFilter(ectx, kind, entity))), ...kinds.map((kind) => ecs.onAdded(kind.component, (entity) => trackFilter(ectx, kind, entity))));
42614
42575
  for (const kind of kinds) for (const [entity] of ecs.query(kind.component)) trackFilter(ectx, kind, entity);
42615
42576
  }
42616
42577
  /**
@@ -53470,16 +53431,6 @@ function entitySet(entities) {
53470
53431
  return new Set(entities);
53471
53432
  }
53472
53433
  /**
53473
- * Takes the hold of the feature a root shows, when the root belongs to a feature.
53474
- *
53475
- * @param ctx - Domain context of the ui plugin.
53476
- * @param feature - The feature of the root's component or projection, if any.
53477
- * @returns The release, or `undefined` for a root of no feature.
53478
- */
53479
- function holdFor(ctx, feature) {
53480
- return feature === void 0 ? void 0 : ctx.deps.world.ecs.hold(feature);
53481
- }
53482
- /**
53483
53434
  * Builds the reconcile half of the jsx module: the two systems of the frame and the root
53484
53435
  * bookkeeping around them.
53485
53436
  *
@@ -54084,7 +54035,6 @@ function createReconciler(ctx, modules, fields) {
54084
54035
  if (root === void 0) continue;
54085
54036
  if (root.element !== void 0 && state.elements.has(root.element)) continue;
54086
54037
  state.removing.delete(entity);
54087
- root.held?.();
54088
54038
  state.roots.delete(entity);
54089
54039
  state.byKey.delete(entity);
54090
54040
  ecs.despawn(entity);
@@ -54502,21 +54452,15 @@ function createReconciler(ctx, modules, fields) {
54502
54452
  if (element?.live === true && root.slot === void 0) ecs.set(element.entity, Order, { value: order });
54503
54453
  }
54504
54454
  /**
54505
- * Reconciles one dirty root: reads its current tree and projection name, then diffs it. A
54506
- * projection root takes the hold of its projection's feature here, at the first reconcile that
54507
- * names its projection: its `Tree` hook fires inside the spawn, before the world knows the view.
54508
- * The root stays dirty while the layout engine is not loaded, and a failure is logged for this
54509
- * root only.
54455
+ * Reconciles one dirty root: reads its current tree and projection name, then diffs it. The root
54456
+ * stays dirty while the layout engine is not loaded, and a failure is logged for this root only.
54510
54457
  *
54511
54458
  * @param root - A root with work this frame.
54512
54459
  */
54513
54460
  function reconcileDirtyRoot(root) {
54514
54461
  const tree = ecs.get(root.entity, Tree);
54515
54462
  const place = ctx.deps.world.projection.keyOf(root.entity);
54516
- if (place !== void 0) {
54517
- root.name = place.projection;
54518
- if (root.popup === void 0) root.held ??= holdFor(ctx, state.featureOfProjection.get(place.projection));
54519
- }
54463
+ if (place !== void 0) root.name = place.projection;
54520
54464
  if (tree !== void 0) root.tree = tree.node;
54521
54465
  refreshRootOrder(root);
54522
54466
  root.dirty = !modules.layout.loaded();
@@ -54593,10 +54537,7 @@ function createReconciler(ctx, modules, fields) {
54593
54537
  /**
54594
54538
  * Records a root: a projection view that returned a tree, or a popup this plugin spawned. The
54595
54539
  * `Tree` hook and the popup handler both call it, in either order, so a second call fills in
54596
- * what the first one could not know. A new popup root holds its component's feature; a root
54597
- * first recorded by the `Tree` hook takes that hold when the popup handler names the component,
54598
- * and a reclaimed root keeps the hold it has. A projection root takes its projection's hold at
54599
- * its first reconcile.
54540
+ * what the first one could not know.
54600
54541
  *
54601
54542
  * @param entity - The entity that carries `Tree`.
54602
54543
  * @param name - The projection name, or the component name of a popup.
@@ -54612,7 +54553,6 @@ function createReconciler(ctx, modules, fields) {
54612
54553
  known.popup = popup;
54613
54554
  known.name = name;
54614
54555
  known.cause = "change";
54615
- known.held ??= holdFor(ctx, state.featureOfComponent.get(name));
54616
54556
  }
54617
54557
  return;
54618
54558
  }
@@ -54636,8 +54576,7 @@ function createReconciler(ctx, modules, fields) {
54636
54576
  cause: popup === void 0 ? void 0 : "change",
54637
54577
  slot: void 0,
54638
54578
  slotRect: void 0,
54639
- pending: false,
54640
- held: popup === void 0 ? void 0 : holdFor(ctx, state.featureOfComponent.get(name))
54579
+ pending: false
54641
54580
  });
54642
54581
  }
54643
54582
  /**
@@ -56872,45 +56811,14 @@ function isComponent(entry) {
56872
56811
  return typeof entry === "function" && entry.isUiComponent === true;
56873
56812
  }
56874
56813
  /**
56875
- * The name of a projection a feature lists, read without trusting the entry.
56876
- *
56877
- * @param entry - What the feature put under its `projections` key.
56878
- * @returns The name, or `undefined` when the entry carries none.
56879
- * @example
56880
- * ```ts
56881
- * projectionName({ name: "shop.sheet", layer: "ui" }); // "shop.sheet"
56882
- * ```
56883
- */
56884
- function projectionName(entry) {
56885
- if (typeof entry !== "object" || entry === null) return void 0;
56886
- const name = entry.name;
56887
- return typeof name === "string" ? name : void 0;
56888
- }
56889
- /**
56890
- * Fills the component registry from the `ui` key of every feature, in feature order, and records
56891
- * which feature each component and each projection belongs to: a root holds that feature while it
56892
- * is shown, so the feature's systems run with `systemsRun: "active"`. Two tables, because a
56893
- * component and a projection may share a name.
56814
+ * Fills the component registry from the `ui` key of every feature, in feature order.
56894
56815
  *
56895
56816
  * @param ctx - Domain context of the ui plugin.
56896
56817
  * @param jsx - The jsx module, which owns the registry.
56897
56818
  * @throws {Error} When two features define the same component name.
56898
56819
  */
56899
56820
  function registerComponents(ctx, jsx) {
56900
- const { featureOfComponent, featureOfProjection } = ctx.state.jsx;
56901
- for (const feature of ctx.deps.flow.features.all()) {
56902
- for (const entry of feature.description.ui ?? []) {
56903
- if (!isComponent(entry)) continue;
56904
- jsx.register(entry);
56905
- featureOfComponent.set(entry.name, feature.name);
56906
- }
56907
- const projections = feature.description.projections;
56908
- if (!Array.isArray(projections)) continue;
56909
- for (const entry of projections) {
56910
- const name = projectionName(entry);
56911
- if (name !== void 0) featureOfProjection.set(name, feature.name);
56912
- }
56913
- }
56821
+ for (const feature of ctx.deps.flow.features.all()) for (const entry of feature.description.ui ?? []) if (isComponent(entry)) jsx.register(entry);
56914
56822
  }
56915
56823
  /**
56916
56824
  * Opens the two systems of phase `layout`, the seven world hooks of `Tree`, `Box`, `Pressed` and
@@ -56990,8 +56898,6 @@ function stopUi(state) {
56990
56898
  state.layout.scrolling = void 0;
56991
56899
  state.layout.iconLine = void 0;
56992
56900
  state.jsx.components.clear();
56993
- state.jsx.featureOfComponent.clear();
56994
- state.jsx.featureOfProjection.clear();
56995
56901
  state.jsx.roots.clear();
56996
56902
  state.jsx.elements.clear();
56997
56903
  state.jsx.byIdentity.clear();
@@ -57387,9 +57293,7 @@ function createJsxState() {
57387
57293
  written: 0,
57388
57294
  debugTaps: [],
57389
57295
  debugWarned: false,
57390
- overlays: emptySet(),
57391
- featureOfComponent: emptyMap$1(),
57392
- featureOfProjection: emptyMap$1()
57296
+ overlays: emptySet()
57393
57297
  };
57394
57298
  }
57395
57299
  //#endregion
@@ -58135,18 +58039,6 @@ function assertFeatures(name, children) {
58135
58039
  for (const [index, child] of children.entries()) if (typeof child?.logicOnly !== "object" || !Array.isArray(child.features)) throw notAFeature(`Feature "${name}"`, `features[${index}]`);
58136
58040
  }
58137
58041
  /**
58138
- * Throws for a `systemsRun` outside its type. The type already says so; a game in JavaScript, or
58139
- * a factory that passes a string through, is caught here, before the world reads it.
58140
- *
58141
- * @param name - The feature that declares it.
58142
- * @param value - What the description holds under `systemsRun`.
58143
- * @throws {Error} When the value is present and neither `"always"` nor `"active"`.
58144
- */
58145
- function assertSystemsRun(name, value) {
58146
- if (value === void 0 || value === "always" || value === "active") return;
58147
- throw new Error(`[game] Feature "${name}" has systemsRun "${String(value)}".\n Use "always" (the default) or "active".`);
58148
- }
58149
- /**
58150
58042
  * Builds the plugin of one feature: it depends on `flow`, checks in `onInit` that the app composes
58151
58043
  * every declared sub-feature, then registers its description, before any plugin above reads the
58152
58044
  * registry in `onStart`.
@@ -58185,14 +58077,12 @@ function featurePluginOf(name, description, children) {
58185
58077
  * same plugin built from the V1 keys of the description, for headless tests. A feature that
58186
58078
  * declares `features` is a parent: its `onInit` throws when one of them is not composed. The
58187
58079
  * plugin carries them in `features`, its `logicOnly` carries their `logicOnly`.
58188
- * `systemsRun: "active"` runs the feature's systems only while it is active (see `SystemsRun`).
58189
58080
  *
58190
58081
  * @param name - Feature name. Shares the namespace with plugin names.
58191
58082
  * @param description - What the feature brings: nodes, flows, slot contributions, sub-features.
58192
58083
  * @returns The feature plugin, with its `logicOnly` twin.
58193
- * @throws {Error} When the name is reserved or belongs to an engine plugin, for an entry of
58194
- * `features` that `defineFeature` did not make, and for a `systemsRun` that is neither
58195
- * `"always"` nor `"active"`.
58084
+ * @throws {Error} When the name is reserved or belongs to an engine plugin, and for an entry of
58085
+ * `features` that `defineFeature` did not make.
58196
58086
  * @example
58197
58087
  * ```ts
58198
58088
  * // The reward popup is a feature: the main flow only declares slot("afterOrder").
@@ -58207,7 +58097,7 @@ function featurePluginOf(name, description, children) {
58207
58097
  */
58208
58098
  function defineFeature(name, description) {
58209
58099
  if (takenNames().has(name)) throw new Error(`[game] Feature name "${name}" is reserved or already a plugin.\n Choose another feature name.`);
58210
- assertSystemsRun(name, description.systemsRun);
58100
+ assertNoSystemsRun(name, description);
58211
58101
  const children = Object.freeze([...description.features ?? []]);
58212
58102
  assertFeatures(name, children);
58213
58103
  const logicOnly = Object.assign(featurePluginOf(name, logicKeysOf(description), children), { features: Object.freeze(children.map((child) => child.logicOnly)) });
@@ -58217,6 +58107,18 @@ function defineFeature(name, description) {
58217
58107
  });
58218
58108
  }
58219
58109
  /**
58110
+ * Throws for a feature that still writes `systemsRun`, removed after 0.15.3: the type refuses it,
58111
+ * this catches a game in JavaScript or a factory that passes the key through.
58112
+ *
58113
+ * @param name - Feature name.
58114
+ * @param description - What the feature brings.
58115
+ * @throws {Error} When the description carries `systemsRun`.
58116
+ */
58117
+ function assertNoSystemsRun(name, description) {
58118
+ if (!("systemsRun" in description)) return;
58119
+ throw new Error(`[game] Feature "${name}" sets systemsRun, which the engine no longer has.\n Delete it: a system whose query has no entity is not run (world README, "When a system runs").`);
58120
+ }
58121
+ /**
58220
58122
  * Tells whether an entry of a feature tree carries its `features` list, as every plugin of
58221
58123
  * `defineFeature` does. A plain plugin, a cast object or a hole in the array does not.
58222
58124
  *