@moku-labs/game 0.15.2 → 0.15.3

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.
@@ -8546,6 +8546,294 @@ function schemaOf(types) {
8546
8546
  return [...types].map((type) => schemaOfType(type)).toSorted((left, right) => left.name.localeCompare(right.name));
8547
8547
  }
8548
8548
  //#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
8549
8837
  //#region src/plugins/world/ecs/systems.ts
8550
8838
  /** The one phase that runs in every mode, because `renderer.sync` lives in it. */
8551
8839
  const ALWAYS = "sync";
@@ -8636,14 +8924,17 @@ function frameSnapshot(ctx, time) {
8636
8924
  }
8637
8925
  /**
8638
8926
  * Registers a system at the end of its phase list and returns the remover. Its query is resolved
8639
- * here, once: a later change to the definition's `query` array is not seen.
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.
8640
8930
  *
8641
8931
  * @param ctx - Domain context of the world plugin.
8642
8932
  * @param definition - The system.
8933
+ * @param scope - The index of its feature in the scope, or -1 for a system that always runs.
8643
8934
  * @returns The remover; calling it twice is a no-op.
8644
8935
  * @throws {Error} When a system of that name is already registered.
8645
8936
  */
8646
- function registerSystem(ctx, definition) {
8937
+ function registerSystem(ctx, definition, scope = -1) {
8647
8938
  const state = ctx.state.ecs;
8648
8939
  for (const phase of [
8649
8940
  "input",
@@ -8656,10 +8947,13 @@ function registerSystem(ctx, definition) {
8656
8947
  definition,
8657
8948
  frame: ctx.deps.time.snapshot().frame,
8658
8949
  plan,
8950
+ scope,
8951
+ skipped: false,
8659
8952
  walk: plan.names.length === 0 ? void 0 : new SystemWalk(ctx, plan)
8660
8953
  };
8661
8954
  const list = state.systems[definition.phase];
8662
8955
  list.push(entry);
8956
+ if (scope !== -1) state.scope.scoped = true;
8663
8957
  if (state.timing !== void 0) giveRow(state, state.timing, definition.name);
8664
8958
  return () => {
8665
8959
  const at = list.indexOf(entry);
@@ -8682,7 +8976,8 @@ function createPhaseContext(state, world, snapshot, time) {
8682
8976
  world,
8683
8977
  res: (resourceType) => resourceValue(state, resourceType),
8684
8978
  snapshot,
8685
- time
8979
+ time,
8980
+ resumed: false
8686
8981
  };
8687
8982
  state.phaseContext = context;
8688
8983
  return context;
@@ -8707,6 +9002,42 @@ function phaseContextOf(ctx, world, time) {
8707
9002
  return known;
8708
9003
  }
8709
9004
  /**
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.
9010
+ *
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.
9013
+ * @param context - What its `run` receives; `resumed` is written here.
9014
+ * @returns True when the system runs.
9015
+ */
9016
+ function wakes(active, entry, context) {
9017
+ if (active[entry.scope] === 0) {
9018
+ entry.skipped = true;
9019
+ return false;
9020
+ }
9021
+ context.resumed = entry.skipped;
9022
+ entry.skipped = false;
9023
+ return true;
9024
+ }
9025
+ /**
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
+ /**
8710
9041
  * Runs one system, reporting a throw: the try lives here, so the loop of a phase holds none.
8711
9042
  *
8712
9043
  * @param ctx - Domain context of the world plugin.
@@ -8726,7 +9057,9 @@ function guardedRun(ctx, entry, context, phase) {
8726
9057
  }
8727
9058
  /**
8728
9059
  * Runs the systems of one phase and flushes the commands they queued. A throwing system is
8729
- * reported and skipped for this frame; the frame goes on.
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.
8730
9063
  *
8731
9064
  * @param ctx - Domain context of the world plugin.
8732
9065
  * @param world - The ecs API a system's `run` receives.
@@ -8736,21 +9069,26 @@ function guardedRun(ctx, entry, context, phase) {
8736
9069
  function runPhase(ctx, world, phase, time) {
8737
9070
  const state = ctx.state.ecs;
8738
9071
  if (phase !== ALWAYS && effectiveMode(ctx) !== "live") return;
9072
+ if (state.scope.scoped) refreshScope(ctx);
8739
9073
  const context = phaseContextOf(ctx, world, time);
8740
9074
  const entries = state.systems[phase];
9075
+ const active = state.scope.active;
8741
9076
  state.running = phase;
8742
9077
  if (state.timing === void 0) for (let index = 0; index < entries.length; index += 1) {
8743
9078
  const entry = entries[index];
8744
- if (entry !== void 0 && entry.frame < time.frame) guardedRun(ctx, entry, context, phase);
9079
+ 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);
8745
9082
  }
8746
9083
  else runTimed(ctx, state.timing, entries, context, phase, time);
8747
9084
  state.running = void 0;
8748
9085
  flushCommands(ctx);
8749
9086
  }
8750
9087
  /**
8751
- * Runs the systems of one phase as `runPhase` does, same order, same error isolation, and adds
8752
- * each run's time to its row of the timing table: two clock reads per run, an indexed loop, no
8753
- * closure. The rows were zeroed at the start of the frame, so after the frame they hold its time.
9088
+ * Runs the systems of one phase as `runPhase` does, same order, same error isolation, same skip,
9089
+ * and adds each run's time to its row of the timing table: two clock reads per run, an indexed
9090
+ * loop, no closure. The rows were zeroed at the start of the frame, so after the frame they hold
9091
+ * its time; a skipped system's row stays 0.
8754
9092
  *
8755
9093
  * @param ctx - Domain context of the world plugin.
8756
9094
  * @param timing - The timing table of the agent build.
@@ -8761,20 +9099,25 @@ function runPhase(ctx, world, phase, time) {
8761
9099
  */
8762
9100
  function runTimed(ctx, timing, entries, context, phase, time) {
8763
9101
  const count = entries.length;
9102
+ const active = ctx.state.ecs.scope.active;
8764
9103
  for (let index = 0; index < count; index += 1) {
8765
9104
  const entry = entries[index];
8766
9105
  if (entry === void 0 || entry.frame >= time.frame) continue;
9106
+ if (entry.scope !== -1 && !wakes(active, entry, context)) continue;
8767
9107
  const row = rowOf(ctx.state.ecs, entry.definition.name);
8768
9108
  const started = timing.clock();
8769
9109
  guardedRun(ctx, entry, context, phase);
8770
9110
  timing.ms[row] = (timing.ms[row] ?? 0) + timing.clock() - started;
9111
+ context.resumed = false;
8771
9112
  }
8772
9113
  }
8773
9114
  //#endregion
8774
9115
  //#region src/plugins/world/ecs/api.ts
8775
9116
  /**
8776
- * Empties the world in place: entities, components, resources, systems, hooks and the frame
8777
- * history. The state object keeps its identity, so every view on it stays valid.
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.
8778
9121
  *
8779
9122
  * @param ctx - Domain context of the world plugin.
8780
9123
  */
@@ -8805,6 +9148,7 @@ function clearWorldState(ctx) {
8805
9148
  state.mode = "live";
8806
9149
  state.frameSnapshot = void 0;
8807
9150
  resetHistory(state);
9151
+ viewsCleared(state.scope);
8808
9152
  }
8809
9153
  /**
8810
9154
  * Creates the ecs module: the public API a game and the plugins above call, plus the four methods
@@ -8817,6 +9161,8 @@ function createEcsApi(ctx) {
8817
9161
  const state = ctx.state.ecs;
8818
9162
  const api = {
8819
9163
  system: (definition) => registerSystem(ctx, definition),
9164
+ hold: (feature) => holdFeature(ctx, feature),
9165
+ isActive: (feature) => isFeatureActive(ctx, feature),
8820
9166
  spawn: (owner, components) => {
8821
9167
  const entity = reserveEntity(state, owner);
8822
9168
  queueOrApply(ctx, {
@@ -8920,6 +9266,15 @@ function createEcsApi(ctx) {
8920
9266
  },
8921
9267
  clear: () => {
8922
9268
  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);
8923
9278
  }
8924
9279
  };
8925
9280
  return api;
@@ -9033,21 +9388,29 @@ function registerEach(ctx, feature, key, accepts, register) {
9033
9388
  }
9034
9389
  /**
9035
9390
  * Registers what every feature brought, in feature order: components first, so their schema is
9036
- * known, then systems, then projections.
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.
9037
9394
  *
9038
9395
  * @param ctx - Domain context of the world plugin.
9039
- * @param ecs - The ecs module.
9040
9396
  * @param projection - The projection module.
9041
9397
  */
9042
- function registerFeatures(ctx, ecs, projection) {
9043
- for (const feature of ctx.deps.flow.features.all()) {
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);
9044
9402
  registerEach(ctx, feature, "components", isComponentEntry, (entry) => registerType(ctx, entry));
9045
- registerEach(ctx, feature, "systems", isSystemEntry, (entry) => ecs.system(entry));
9046
- registerEach(ctx, feature, "projections", isProjectionEntry, (entry) => projection.register(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);
9047
9409
  }
9048
9410
  }
9049
9411
  /**
9050
- * Connects the world in `onStart`: it reads every feature description, registers the five frame
9412
+ * Connects the world in `onStart`: it sizes the scope to the features, reads every feature
9413
+ * description, registers the five frame
9051
9414
  * callbacks of the phases and listens to the released hints. In a dev build the `signals`
9052
9415
  * callback records the frame history before it clears the change sets; in an agent build the
9053
9416
  * timing table starts with the page clock of `time` and the `input` callback zeroes it each
@@ -9068,7 +9431,8 @@ function connectWorld(ctx) {
9068
9431
  const clock = time.agentClock();
9069
9432
  if (clock !== void 0) startTiming(worldCtx.state.ecs, clock);
9070
9433
  }
9071
- registerFeatures(worldCtx, ecs, projection);
9434
+ openScope(worldCtx);
9435
+ registerFeatures(worldCtx, projection);
9072
9436
  ecs.resource(Now).now = clock.now();
9073
9437
  worldCtx.state.ecs.offFrame.push(ecs.onOwnerLeft((owner) => projection.ownerLeft(owner)), time.onFrame("input", (frame) => {
9074
9438
  ecs.resource(Now).now = clock.now();
@@ -9086,7 +9450,8 @@ function connectWorld(ctx) {
9086
9450
  }
9087
9451
  /**
9088
9452
  * Empties the world in `onStop`: the frame callbacks and the hint listener are removed, then
9089
- * every track, view, queued view, entity and resource is dropped and the frame history is reset.
9453
+ * every track, view, queued view, entity and resource is dropped, the scope and the frame history
9454
+ * are reset.
9090
9455
  * No `onRemoved` fires, because `renderer` stopped earlier and destroyed its own objects.
9091
9456
  *
9092
9457
  * @param state - The plugin state, the only thing a teardown context carries.
@@ -9137,6 +9502,7 @@ function clearWorld(state) {
9137
9502
  state.ecs.mode = "live";
9138
9503
  state.ecs.timing = void 0;
9139
9504
  state.ecs.timingSlots.clear();
9505
+ resetScope(state.ecs.scope);
9140
9506
  resetHistory(state.ecs);
9141
9507
  }
9142
9508
  //#endregion
@@ -10049,7 +10415,7 @@ function despawnView(pctx, mounted, view) {
10049
10415
  view.handles = [];
10050
10416
  cancelTracks(pctx, view.entity);
10051
10417
  const state = pctx.ctx.state.projection;
10052
- state.byEntity.delete(view.entity);
10418
+ if (state.byEntity.delete(view.entity)) pctx.deps.views.left(view.projection);
10053
10419
  state.mutes.delete(view.entity);
10054
10420
  state.rests.delete(view.entity);
10055
10421
  state.leaving.set(view.entity, view.cause);
@@ -10425,6 +10791,7 @@ function enterEntry(pctx, spec, mounted, entry) {
10425
10791
  const view = createViewRecord(entity, spec.name, entry.key, entry.item, values, entry.direct ? "mount" : "change");
10426
10792
  mounted.live.set(entry.key, view);
10427
10793
  pctx.ctx.state.projection.byEntity.set(entity, view);
10794
+ pctx.deps.views.came(spec.name);
10428
10795
  pctx.ctx.state.projection.membershipVersion += 1;
10429
10796
  const hook = spec.motion?.enter;
10430
10797
  const loop = spec.motion?.loop;
@@ -11237,6 +11604,7 @@ function createProjectionApi(ctx, deps) {
11237
11604
  state.mounted.clear();
11238
11605
  state.specs.clear();
11239
11606
  state.byEntity.clear();
11607
+ pctx.deps.views.cleared();
11240
11608
  state.mutes.clear();
11241
11609
  state.rests.clear();
11242
11610
  state.keys.clear();
@@ -11276,7 +11644,12 @@ function createModules$2(ctx) {
11276
11644
  ecs,
11277
11645
  projection: createProjectionApi(ctx, {
11278
11646
  ecs,
11279
- components: WORLD_COMPONENTS
11647
+ components: WORLD_COMPONENTS,
11648
+ views: {
11649
+ came: ecs.viewCame,
11650
+ left: ecs.viewLeft,
11651
+ cleared: ecs.viewsCleared
11652
+ }
11280
11653
  })
11281
11654
  };
11282
11655
  }
@@ -11311,6 +11684,8 @@ function withKeys(diff, ecs, projection) {
11311
11684
  function exposeEcs(ecs, projection) {
11312
11685
  return {
11313
11686
  system: ecs.system,
11687
+ hold: ecs.hold,
11688
+ isActive: ecs.isActive,
11314
11689
  spawn: ecs.spawn,
11315
11690
  despawn: ecs.despawn,
11316
11691
  despawnOwnedBy: ecs.despawnOwnedBy,
@@ -11432,6 +11807,29 @@ function emptyMap$10() {
11432
11807
  return /* @__PURE__ */ new Map();
11433
11808
  }
11434
11809
  /**
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.
11812
+ *
11813
+ * @returns An empty scope.
11814
+ */
11815
+ function emptyScope() {
11816
+ 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
11830
+ };
11831
+ }
11832
+ /**
11435
11833
  * Creates the initial ecs state: no entity, no type, no system, no resource and no frame history:
11436
11834
  * a dev build starts one on its first frame. Every collection is its own object, so two apps in
11437
11835
  * one process never share a world.
@@ -11473,7 +11871,8 @@ function createEcsState() {
11473
11871
  walkReported: false,
11474
11872
  queryPlans: emptyMap$10(),
11475
11873
  walkLists: [],
11476
- despawns: 0
11874
+ despawns: 0,
11875
+ scope: emptyScope()
11477
11876
  };
11478
11877
  }
11479
11878
  //#endregion
@@ -11585,7 +11984,7 @@ const worldPlugin = /*#__PURE__*/ createPlugin$1("world", {
11585
11984
  */
11586
11985
  function slotsPadding(slots) {
11587
11986
  let pad = 0;
11588
- for (const slot of slots) pad += slot.filter.padding;
11987
+ for (let index = 0; index < slots.length; index += 1) pad += slots[index]?.filter.padding ?? 0;
11589
11988
  return pad;
11590
11989
  }
11591
11990
  /**
@@ -11745,8 +12144,9 @@ function checkMissedMarks(ctx, state, stage, views, shown) {
11745
12144
  //#endregion
11746
12145
  //#region src/plugins/renderer/demand/shown.ts
11747
12146
  /**
11748
- * The paddings of every filter a node holds, enabled or not: a superset of what any of them could spill onto the
11749
- * screen. Pixi answers `undefined` before the first write and `null` after a clear.
12147
+ * The paddings of every filter a node holds: what they could spill onto the screen. Pixi holds the enabled filters of
12148
+ * a view (E3), and a filter switched on reaches Pixi before the decision judges the marks again. Pixi answers
12149
+ * `undefined` before the first write and `null` after a clear.
11750
12150
  *
11751
12151
  * @param node - A display object on the chain of a view.
11752
12152
  * @returns The sum of the paddings; 0 without a filter.
@@ -11755,7 +12155,7 @@ function paddingOf$2(node) {
11755
12155
  const filters = node.filters;
11756
12156
  if (filters === null || filters === void 0) return 0;
11757
12157
  let pad = 0;
11758
- for (const filter of filters) pad += filter.padding;
12158
+ for (let index = 0; index < filters.length; index += 1) pad += filters[index]?.padding ?? 0;
11759
12159
  return pad;
11760
12160
  }
11761
12161
  /** What `chainPadding` answers for a chain that is not drawn. */
@@ -11777,6 +12177,15 @@ function noAlpha() {
11777
12177
  return false;
11778
12178
  }
11779
12179
  /**
12180
+ * The reader without the engine's records: Pixi's own `alpha`.
12181
+ *
12182
+ * @param node - A node on the chain of a view.
12183
+ * @returns True at alpha 0 or below.
12184
+ */
12185
+ function alphaZero(node) {
12186
+ return node.alpha <= 0;
12187
+ }
12188
+ /**
11780
12189
  * Walks from a node up to the stage: every node on the way is visible, renderable (or a kept top its quad draws) and
11781
12190
  * not fully transparent, and the chain reaches the stage. The paddings of the filters met on the way are summed; no
11782
12191
  * object is made.
@@ -11784,14 +12193,15 @@ function noAlpha() {
11784
12193
  * @param from - The first node of the walk.
11785
12194
  * @param stage - The stage of the application.
11786
12195
  * @param through - The kept reader: the padding of a kept top drawn through its quad, or `HIDDEN`.
12196
+ * @param transparent - Whether a node is drawn at alpha 0.
11787
12197
  * @param stop - Where the walk notes a visible node at alpha 0 it stopped at; left as it is for any other stop.
11788
12198
  * @returns The sum of the paddings of the chain, or `HIDDEN` when the chain is not drawn.
11789
12199
  */
11790
- function chainPadding(from, stage, through, stop) {
12200
+ function chainPadding(from, stage, through, transparent, stop) {
11791
12201
  let pad = 0;
11792
12202
  for (let node = from; node !== stage; node = node.parent) {
11793
12203
  if (node === null || !node.visible) return HIDDEN;
11794
- if (node.alpha <= 0) {
12204
+ if (transparent(node)) {
11795
12205
  if (stop !== void 0) stop.node = node;
11796
12206
  return HIDDEN;
11797
12207
  }
@@ -11861,15 +12271,16 @@ function alphaStop(node, top, alphaHidden) {
11861
12271
  * @param entity - The entity.
11862
12272
  * @param through - The kept reader: the padding of a kept top drawn through its quad, or `HIDDEN`; none by default.
11863
12273
  * @param alphaHidden - Sync's reader of the `"alpha"` reason; none by default.
12274
+ * @param transparent - Whether a node is drawn at alpha 0; Pixi's `alpha` by default.
11864
12275
  * @returns Where the walk stopped.
11865
12276
  */
11866
- function unseenBy(state, host, views, entity, through = noneThrough, alphaHidden = noAlpha) {
12277
+ function unseenBy(state, host, views, entity, through = noneThrough, alphaHidden = noAlpha, transparent = alphaZero) {
11867
12278
  const view = views.get(entity);
11868
12279
  const stage = host.stage();
11869
12280
  if (view === void 0 || stage === void 0) return "other";
11870
12281
  const top = view.wrapper ?? view.object;
11871
12282
  state.stop.node = void 0;
11872
- const pad = chainPadding(top, stage, through, state.stop);
12283
+ const pad = chainPadding(top, stage, through, transparent, state.stop);
11873
12284
  if (pad === HIDDEN) return alphaStop(state.stop.node, top, alphaHidden);
11874
12285
  if (view.wrapper !== void 0 || top.children.length > 0) return "shown";
11875
12286
  return onScreen(state, host, top, pad) ? "shown" : "off-screen";
@@ -11882,10 +12293,11 @@ function unseenBy(state, host, views, entity, through = noneThrough, alphaHidden
11882
12293
  * @param views - The views by entity.
11883
12294
  * @param entity - The marked entity.
11884
12295
  * @param through - The kept reader: the padding of a kept top drawn through its quad, or `HIDDEN`; none by default.
12296
+ * @param transparent - Whether a node is drawn at alpha 0; Pixi's `alpha` by default.
11885
12297
  * @returns True when the view is drawn.
11886
12298
  */
11887
- function isShown$2(state, host, views, entity, through = noneThrough) {
11888
- return unseenBy(state, host, views, entity, through) === "shown";
12299
+ function isShown$2(state, host, views, entity, through = noneThrough, transparent = alphaZero) {
12300
+ return unseenBy(state, host, views, entity, through, noAlpha, transparent) === "shown";
11889
12301
  }
11890
12302
  /**
11891
12303
  * The ancestor rule of loose marks: the walk of `isShown` from the parent of the view's top node, without bounds. A
@@ -11896,13 +12308,14 @@ function isShown$2(state, host, views, entity, through = noneThrough) {
11896
12308
  * @param views - The views by entity.
11897
12309
  * @param entity - The entity of a loose mark.
11898
12310
  * @param through - The kept reader: the padding of a kept top drawn through its quad, or `HIDDEN`; none by default.
12311
+ * @param transparent - Whether a node is drawn at alpha 0; Pixi's `alpha` by default.
11899
12312
  * @returns True when the ancestors of the view draw it.
11900
12313
  */
11901
- function ancestorsShown(host, views, entity, through = noneThrough) {
12314
+ function ancestorsShown(host, views, entity, through = noneThrough, transparent = alphaZero) {
11902
12315
  const view = views.get(entity);
11903
12316
  const stage = host.stage();
11904
12317
  if (view === void 0 || stage === void 0) return false;
11905
- return chainPadding((view.wrapper ?? view.object).parent, stage, through) !== HIDDEN;
12318
+ return chainPadding((view.wrapper ?? view.object).parent, stage, through, transparent) !== HIDDEN;
11906
12319
  }
11907
12320
  //#endregion
11908
12321
  //#region src/plugins/renderer/pixels.ts
@@ -12025,6 +12438,15 @@ function firstWhole(wholes) {
12025
12438
  return wholes.size === 0 ? void 0 : wholes.values().next().value;
12026
12439
  }
12027
12440
  /**
12441
+ * Whether a node is drawn at alpha 0, read from Pixi: the reader of a demand module built without sync's records.
12442
+ *
12443
+ * @param node - A node on the chain of a view.
12444
+ * @returns True at alpha 0 or below.
12445
+ */
12446
+ function pixiTransparent(node) {
12447
+ return node.alpha <= 0;
12448
+ }
12449
+ /**
12028
12450
  * Tells whether one entity of a mark list passes a test, walked by index: no iterator.
12029
12451
  *
12030
12452
  * @param entities - The marks.
@@ -12067,11 +12489,12 @@ function clearMarks(state) {
12067
12489
  * @param deps.host - The host module.
12068
12490
  * @param deps.views - The views by entity: `ctx.state.sync.views`, whose identity sync keeps.
12069
12491
  * @param deps.hiddenBy - Sync's `hiddenBy`: whether one reason hides a view (spec 26 R1.7.1).
12492
+ * @param deps.transparent - Sync's reader of a node drawn at alpha 0 from its records (E4); Pixi's `alpha` without it.
12070
12493
  * @returns The demand module.
12071
12494
  */
12072
12495
  function createDemandApi(ctx, deps) {
12073
12496
  const state = ctx.state.demand;
12074
- const { host, views, hiddenBy } = deps;
12497
+ const { host, views, hiddenBy, transparent = pixiTransparent } = deps;
12075
12498
  /**
12076
12499
  * Whether the render phase decides this frame: `onDemand` is on, or the pixel oracle runs in shadow.
12077
12500
  *
@@ -12084,7 +12507,7 @@ function createDemandApi(ctx, deps) {
12084
12507
  * @param entity - The entity.
12085
12508
  * @returns True when its view is drawn as the frame stands.
12086
12509
  */
12087
- const shown = (entity) => isShown$2(state, host, views, entity, through);
12510
+ const shown = (entity) => isShown$2(state, host, views, entity, through, transparent);
12088
12511
  /**
12089
12512
  * Whether a node is the top or the visual of a view hidden by `"alpha"` (spec 26 R1.7.1), read from sync's state.
12090
12513
  *
@@ -12110,7 +12533,7 @@ function createDemandApi(ctx, deps) {
12110
12533
  * @param entity - The entity of a loose mark.
12111
12534
  * @returns True when it counts.
12112
12535
  */
12113
- const looseShown = (entity) => ancestorsShown(host, views, entity, through);
12536
+ const looseShown = (entity) => ancestorsShown(host, views, entity, through, transparent);
12114
12537
  /**
12115
12538
  * Notes an entity in the record since the last drawn frame, in a dev or an agent build.
12116
12539
  *
@@ -12226,7 +12649,7 @@ function createDemandApi(ctx, deps) {
12226
12649
  settle,
12227
12650
  frame: () => state.frame,
12228
12651
  shown,
12229
- unseenBy: (entity) => unseenBy(state, host, views, entity, through, alphaHidden),
12652
+ unseenBy: (entity) => unseenBy(state, host, views, entity, through, alphaHidden, transparent),
12230
12653
  marked: () => state.marked,
12231
12654
  loose: () => state.loose,
12232
12655
  visual: () => state.visual,
@@ -12617,9 +13040,60 @@ const PROGRAM_NAME = "sdf-shader-single-alpha";
12617
13040
  * ```
12618
13041
  */
12619
13042
  function scaleOf$1(linear) {
12620
- return (Math.hypot(linear.a, linear.b) + Math.hypot(linear.c, linear.d)) / 2;
13043
+ const { a, b, c, d } = linear;
13044
+ return (Math.sqrt(a * a + b * b) + Math.sqrt(c * c + d * d)) / 2;
12621
13045
  }
12622
13046
  /**
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.
13054
+ *
13055
+ * @param this - The text's uniforms.
13056
+ * @returns The distance in device pixels.
13057
+ */
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
+ };
13096
+ /**
12623
13097
  * The local uniforms of one text, in the order of the WGSL struct, at Pixi's starting values. The
12624
13098
  * pipe writes `uDistance`, the graphics adaptor the rest, before every draw.
12625
13099
  *
@@ -12639,6 +13113,9 @@ function scaleOf$1(linear) {
12639
13113
  * keeps the distance per unit of the group scale it was made with; a read answers that times the
12640
13114
  * text's world scale. A write without a text, or at a group scale of 0, is answered as Pixi wrote it.
12641
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.
13118
+ *
12642
13119
  * @param pixi - The loaded Pixi module.
12643
13120
  * @param density - What the device pixels per CSS pixel are read from at each draw: the renderer.
12644
13121
  * @param density.resolution - The renderer's resolution, read at each upload.
@@ -12669,21 +13146,22 @@ function createLocalUniforms(pixi, density, writing) {
12669
13146
  type: "f32"
12670
13147
  }
12671
13148
  });
12672
- let css = 4;
12673
- let text;
12674
- let perScale = 0;
12675
- Object.defineProperty(group.uniforms, "uDistance", {
12676
- enumerable: true,
12677
- get: () => {
12678
- return (text !== void 0 && perScale !== 0 && text !== void 0 ? perScale * scaleOf$1(text.worldTransform) : css) * density.resolution;
12679
- },
12680
- set: (value) => {
12681
- const scale = writing.text === void 0 ? 0 : scaleOf$1(writing.text.groupTransform);
12682
- css = value;
12683
- text = writing.text;
12684
- perScale = scale > 0 ? value / scale : 0;
12685
- }
12686
- });
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;
12687
13165
  return group;
12688
13166
  }
12689
13167
  /**
@@ -14018,8 +14496,9 @@ function endLoan(inputs) {
14018
14496
  * @returns The slot and its inputs, or `undefined`.
14019
14497
  */
14020
14498
  function firstInputs(slots) {
14021
- for (const slot of slots) {
14022
- if (!slot.filter.enabled) continue;
14499
+ for (let index = 0; index < slots.length; index += 1) {
14500
+ const slot = slots[index];
14501
+ if (slot === void 0 || !slot.filter.enabled) continue;
14023
14502
  return slot.inputs === void 0 ? void 0 : {
14024
14503
  slot,
14025
14504
  inputs: slot.inputs
@@ -14256,7 +14735,11 @@ function renderStage3(kctx, pixi, matrices, record, now) {
14256
14735
  key[KEY.s3Tx] = now.tx;
14257
14736
  key[KEY.s3Ty] = now.ty;
14258
14737
  let passes = 1;
14259
- for (const slot of record.slots) if (slot.filter.enabled) passes += slot.passes;
14738
+ const slots = record.slots;
14739
+ for (let index = 0; index < slots.length; index += 1) {
14740
+ const slot = slots[index];
14741
+ if (slot?.filter.enabled === true) passes += slot.passes;
14742
+ }
14260
14743
  kctx.state.flushPasses += passes;
14261
14744
  return true;
14262
14745
  }
@@ -14662,7 +15145,8 @@ function createKeptState(config) {
14662
15145
  warnedBudget: false,
14663
15146
  verifySkipped: 0,
14664
15147
  bypassed: false,
14665
- flushPasses: 0
15148
+ flushPasses: 0,
15149
+ calm: false
14666
15150
  };
14667
15151
  }
14668
15152
  /**
@@ -14798,11 +15282,12 @@ function readStack(slots, resolution, into) {
14798
15282
  let enabled = 0;
14799
15283
  let rs = Number.POSITIVE_INFINITY;
14800
15284
  let padding = 0;
14801
- for (const slot of slots) {
14802
- if (!slot.filter.enabled) continue;
15285
+ for (let index = 0; index < slots.length; index += 1) {
15286
+ const filter = slots[index]?.filter;
15287
+ if (filter === void 0 || !filter.enabled) continue;
14803
15288
  enabled += 1;
14804
- rs = Math.min(rs, resolutionOf(slot.filter, resolution));
14805
- padding += slot.filter.padding;
15289
+ rs = Math.min(rs, resolutionOf(filter, resolution));
15290
+ padding += filter.padding;
14806
15291
  }
14807
15292
  into.enabled = enabled;
14808
15293
  into.rs = enabled === 0 ? resolution : rs;
@@ -14834,8 +15319,9 @@ function holdsFilters(node) {
14834
15319
  * @returns True when they do.
14835
15320
  */
14836
15321
  function plainFilters(slots) {
14837
- for (const slot of slots) {
14838
- const { filter } = slot;
15322
+ for (let index = 0; index < slots.length; index += 1) {
15323
+ const filter = slots[index]?.filter;
15324
+ if (filter === void 0) continue;
14839
15325
  if (filter.enabled && (filter.blendRequired || !plainBlend(filter.blendMode))) return false;
14840
15326
  }
14841
15327
  return true;
@@ -15126,16 +15612,18 @@ function neededBytes(record, now, box) {
15126
15612
  return bytes;
15127
15613
  }
15128
15614
  /**
15129
- * Hands the filters of a live record from its top to its stand-in, before stage 1 renders the top without them.
15615
+ * Hands the filters of a live record from its top to its stand-in, before stage 1 renders the top without them: the
15616
+ * enabled ones, as `sync` hangs them (E3).
15130
15617
  *
15131
15618
  * @param kctx - The kept context.
15132
15619
  * @param pixi - The module that draws.
15133
15620
  * @param record - The record.
15621
+ * @param view - Its view.
15134
15622
  */
15135
- function handFilters(kctx, pixi, record) {
15623
+ function handFilters(kctx, pixi, record, view) {
15136
15624
  const standIn = standInOf(pixi, record);
15137
15625
  kctx.sync.clearFilters(record.top);
15138
- standIn.filters = filtersOf$1(kctx.ctx.state.sync, record.entity).map((slot) => slot.filter);
15626
+ kctx.sync.applyFilters(view, filtersOf$1(kctx.ctx.state.sync, record.entity), noMark$1, standIn);
15139
15627
  }
15140
15628
  /**
15141
15629
  * Writes the take keys of a record: the linear part, the stack resolution and the translation of this frame, and the
@@ -15240,7 +15728,7 @@ function isEmptyBounds(bounds) {
15240
15728
  */
15241
15729
  function renderStages(kctx, pixi, matrices, record, view, now) {
15242
15730
  const live = record.mode === "live";
15243
- if (live) handFilters(kctx, pixi, record);
15731
+ if (live) handFilters(kctx, pixi, record, view);
15244
15732
  const first = firstInputs(record.slots);
15245
15733
  let ok = renderStage1(kctx, pixi, matrices, record);
15246
15734
  if (ok && first !== void 0) renderStage2(kctx, pixi, matrices, record, {
@@ -15538,6 +16026,23 @@ function drawingChanged(kctx, record, before) {
15538
16026
  return before.mode !== record.mode || before.passes !== kctx.state.flushPasses || !Object.is(before.qtx, record.key[KEY.qtx]) || !Object.is(before.qty, record.key[KEY.qty]) || before.shown !== quadShown(record);
15539
16027
  }
15540
16028
  /**
16029
+ * The frame of a kept record in a calm frame (no mark, no whole-frame reason) that owes nothing: no dirty flag, no
16030
+ * settle retake, no churn, not bypassed. Its step would find every key as it stands and its quad where it is, so it is
16031
+ * counted as drawn from its picture and left alone (E5).
16032
+ *
16033
+ * @param kctx - The kept context.
16034
+ * @param record - The record.
16035
+ * @returns True when the record was drawn as it stands; false when it is to be stepped.
16036
+ */
16037
+ function stepCalm(kctx, record) {
16038
+ const { state } = kctx;
16039
+ if (!state.calm || state.bypassed || record.mode !== "kept" || record.dirtyS1 || record.dirtyS2 || record.dirtyS3 || record.settle || record.churn !== 0) return false;
16040
+ record.lastDrawn = state.frame;
16041
+ state.drawnPictures += 1;
16042
+ record.quiet += 1;
16043
+ return true;
16044
+ }
16045
+ /**
15541
16046
  * One drawn frame of a record, inside the flush: eligibility, the keys of this frame, the policy of its life, the quad
15542
16047
  * of a kept one; the dirty flags are cleared.
15543
16048
  *
@@ -15639,7 +16144,7 @@ function bypassRecord(kctx, record, view, live) {
15639
16144
  return;
15640
16145
  }
15641
16146
  kctx.sync.clearFilters(record.top);
15642
- if (record.standIn !== void 0) record.standIn.filters = filters.map((slot) => slot.filter);
16147
+ if (record.standIn !== void 0) kctx.sync.applyFilters(view, filters, noMark$1, record.standIn);
15643
16148
  kctx.sync.swapHidden(kctx.hidden, view, "kept", true);
15644
16149
  if (record.quad !== void 0) record.quad.visible = record.top.visible && !kctx.sync.hiddenBy(view, "alpha") && !kctx.sync.hiddenBy(view, "cover");
15645
16150
  }
@@ -15849,7 +16354,7 @@ function stepRecords(kctx, pixi, matrices) {
15849
16354
  continue;
15850
16355
  }
15851
16356
  const view = kctx.ctx.state.sync.views.get(record.entity);
15852
- if (view !== void 0) stepRecord(kctx, pixi, matrices, record, view);
16357
+ if (view !== void 0 && !stepCalm(kctx, record)) stepRecord(kctx, pixi, matrices, record, view);
15853
16358
  }
15854
16359
  }
15855
16360
  /**
@@ -15902,9 +16407,10 @@ function createKeptApi(ctx, deps) {
15902
16407
  const bypass = (on) => {
15903
16408
  if (state.bypassed === on) return;
15904
16409
  state.bypassed = on;
15905
- for (const record of state.records) {
15906
- const view = viewOf(record);
15907
- if (record.mode === "kept" && view !== void 0) bypassRecord(kctx, record, view, on);
16410
+ for (let index = 0; index < state.records.length; index += 1) {
16411
+ const record = state.records[index];
16412
+ const view = record === void 0 ? void 0 : viewOf(record);
16413
+ if (record?.mode === "kept" && view !== void 0) bypassRecord(kctx, record, view, on);
15908
16414
  }
15909
16415
  };
15910
16416
  /**
@@ -15913,7 +16419,9 @@ function createKeptApi(ctx, deps) {
15913
16419
  */
15914
16420
  const sortRecords = () => {
15915
16421
  let changed = state.unsorted;
15916
- for (const record of state.records) {
16422
+ for (let index = 0; index < state.records.length; index += 1) {
16423
+ const record = state.records[index];
16424
+ if (record === void 0) continue;
15917
16425
  const depth = depthOf(record.top);
15918
16426
  if (depth !== record.depth) {
15919
16427
  record.depth = depth;
@@ -15982,6 +16490,7 @@ function createKeptApi(ctx, deps) {
15982
16490
  if (wholes.has("debug") || wholes.has("restore")) for (const record of state.records) record.dirtyS1 = true;
15983
16491
  const marked = deps.demand.marked();
15984
16492
  const loose = deps.demand.loose();
16493
+ state.calm = wholes.size === 0 && marked.size === 0 && loose.size === 0;
15985
16494
  for (let index = 0; index < marked.size; index += 1) collectOne(marked.at(index));
15986
16495
  for (let index = 0; index < loose.size; index += 1) collectOne(loose.at(index));
15987
16496
  },
@@ -15995,6 +16504,7 @@ function createKeptApi(ctx, deps) {
15995
16504
  if (state.on) {
15996
16505
  sortRecords();
15997
16506
  stepRecords(kctx, pixi, matrices);
16507
+ state.calm = false;
15998
16508
  } else for (const record of state.records) if (record.mode === "kept") live(record);
15999
16509
  flushRanges(kctx);
16000
16510
  },
@@ -16379,7 +16889,7 @@ const ALL_SECTIONS = Object.freeze({
16379
16889
  * @param next - The switches asked for; `undefined` for none.
16380
16890
  * @returns The sections, frozen; `base` itself when nothing differs.
16381
16891
  */
16382
- function switched(base, next) {
16892
+ function switched$1(base, next) {
16383
16893
  if (next === void 0) return base;
16384
16894
  const sections = { ...base };
16385
16895
  let changed = false;
@@ -18211,7 +18721,7 @@ function createSinks(draws) {
18211
18721
  */
18212
18722
  function createPerfState(config, sinks) {
18213
18723
  const events = createEventRing();
18214
- const sections = switched(ALL_SECTIONS, config.sections);
18724
+ const sections = switched$1(ALL_SECTIONS, config.sections);
18215
18725
  sinks.sections = bitsOf(sections);
18216
18726
  sinks.sectionsNext = sinks.sections;
18217
18727
  return {
@@ -20959,7 +21469,7 @@ function stopPerf(perf, sinks) {
20959
21469
  */
20960
21470
  function switchSections(pc, next) {
20961
21471
  const { perf, sinks } = pc;
20962
- const sections = switched(perf.sections, next);
21472
+ const sections = switched$1(perf.sections, next);
20963
21473
  if (sections === perf.sections) return sections;
20964
21474
  perf.sections = sections;
20965
21475
  sinks.sectionsNext = bitsOf(sections);
@@ -20980,7 +21490,7 @@ const READ_ONLY_SECTIONS = "[game] monitor.sections switches the sections only o
20980
21490
  */
20981
21491
  function refuseSections(pc, next) {
20982
21492
  const { sections } = pc.perf;
20983
- if (switched(sections, next) === sections) return sections;
21493
+ if (switched$1(sections, next) === sections) return sections;
20984
21494
  throw Object.assign(/* @__PURE__ */ new Error(READ_ONLY_SECTIONS), { reason: "read-only" });
20985
21495
  }
20986
21496
  //#endregion
@@ -21545,6 +22055,23 @@ function hiddenBy(view, reason) {
21545
22055
  return (view[maskOf(view, reason)] & BITS[reason]) !== 0;
21546
22056
  }
21547
22057
  /**
22058
+ * Whether a node on the chain of a view is drawn at alpha 0, read from the engine's records (E4): Pixi's `alpha` getter
22059
+ * hands its double back boxed at every read of a walk of `shown`. The top of a built-in visual, its wrapper or its
22060
+ * object, draws at alpha 0 exactly when the `"alpha"` reason hides it, and so does an adapter's own object. A node of no
22061
+ * view on a chain is the engine's (a clip container, a layer, the root), never faded. A game's object and the wrapper
22062
+ * of an adapter carry an alpha the engine does not hold: Pixi's is read.
22063
+ *
22064
+ * @param state - The sync state.
22065
+ * @param node - A node on the chain of a view, below the stage.
22066
+ * @returns True at alpha 0.
22067
+ */
22068
+ function transparentNode(state, node) {
22069
+ const view = state.viewOfTop.get(node);
22070
+ if (view === void 0) return false;
22071
+ if (view.kind === "Display" || view.kind === "adapter" && node === view.wrapper) return node.alpha <= 0;
22072
+ return hiddenBy(view, "alpha");
22073
+ }
22074
+ /**
21548
22075
  * Tells whether a node is hidden, and by these reasons alone: spec 23's eligibility of a picture whose ancestor is in
21549
22076
  * an overlay's range (`"freeze"`) or is itself a kept top drawn through its quad (`"kept"`).
21550
22077
  *
@@ -21678,6 +22205,8 @@ function clearAdapters(state) {
21678
22205
  }
21679
22206
  //#endregion
21680
22207
  //#region src/plugins/renderer/sync/filters.ts
22208
+ /** What a display object without filters holds: one shared list, plain, so a still frame's compare makes none. */
22209
+ const NO_FILTERS = [];
21681
22210
  /**
21682
22211
  * The filters a display object holds now. Pixi answers `undefined` before the first write and
21683
22212
  * `null` after a clear, which its getter type does not say.
@@ -21686,10 +22215,11 @@ function clearAdapters(state) {
21686
22215
  * @returns The filters, empty when there are none.
21687
22216
  */
21688
22217
  function heldFilters(target) {
21689
- return target.filters ?? [];
22218
+ return target.filters ?? NO_FILTERS;
21690
22219
  }
21691
22220
  /**
21692
- * Tells whether a display object holds exactly these filter instances, in this order.
22221
+ * Tells whether a display object holds exactly the enabled filters of these slots, in their order (E3): Pixi is handed
22222
+ * no disabled filter, which draws nothing but costs a filter push and pop, two batch breaks, every frame.
21693
22223
  *
21694
22224
  * @param target - The display object.
21695
22225
  * @param slots - The slots to compare with.
@@ -21697,7 +22227,37 @@ function heldFilters(target) {
21697
22227
  */
21698
22228
  function holds(target, slots) {
21699
22229
  const held = heldFilters(target);
21700
- return held.length === slots.length && slots.every((slot, index) => held[index] === slot.filter);
22230
+ let at = 0;
22231
+ for (let index = 0; index < slots.length; index += 1) {
22232
+ const filter = slots[index]?.filter;
22233
+ if (filter === void 0 || !filter.enabled) continue;
22234
+ if (held[at] !== filter) return false;
22235
+ at += 1;
22236
+ }
22237
+ return at === held.length;
22238
+ }
22239
+ /**
22240
+ * The enabled filters of slots, in their order: what Pixi is handed.
22241
+ *
22242
+ * @param slots - The slots.
22243
+ * @returns A new list, or `undefined` when no filter is enabled.
22244
+ */
22245
+ function enabledFilters(slots) {
22246
+ let enabled;
22247
+ for (const slot of slots) if (slot.filter.enabled) {
22248
+ enabled ??= [];
22249
+ enabled.push(slot.filter);
22250
+ }
22251
+ return enabled;
22252
+ }
22253
+ /**
22254
+ * Writes the enabled filters of slots onto a display object, or clears them when none is enabled.
22255
+ *
22256
+ * @param target - The display object.
22257
+ * @param slots - The slots.
22258
+ */
22259
+ function writeFilters(target, slots) {
22260
+ target.filters = enabledFilters(slots) ?? null;
21701
22261
  }
21702
22262
  /**
21703
22263
  * Takes every filter off a display object. Nothing is written when it holds none: Pixi copies the
@@ -21711,8 +22271,10 @@ function clearFilters(target) {
21711
22271
  /**
21712
22272
  * Hangs the filters on a view: on its wrapper when it has one, so they cover the entity's whole
21713
22273
  * subtree, otherwise on the object; on the stand-in of its kept picture while it has one
21714
- * (`kept.filterTarget`, spec 23 P9.1). Written only when the instances differ from what the target
21715
- * holds, and `before` runs once just before that write: the caller marks the entity there.
22274
+ * (`kept.filterTarget`, spec 23 P9.1). Only the enabled ones: a disabled filter draws nothing, and Pixi would push and
22275
+ * pop it around the view every frame (E3); a view whose filters are all disabled holds none. Written only when they
22276
+ * differ from what the target holds, and `before` runs once just before that write: the caller marks the entity there.
22277
+ * A filter switched on or off later is written by `applySwitched` in the frame it was switched.
21716
22278
  *
21717
22279
  * @param view - The view.
21718
22280
  * @param slots - The filters in draw order; empty clears them.
@@ -21722,7 +22284,7 @@ function clearFilters(target) {
21722
22284
  function applyFilters(view, slots, before, target = view.wrapper ?? view.object) {
21723
22285
  if (holds(target, slots)) return;
21724
22286
  before();
21725
- target.filters = slots.length === 0 ? null : slots.map((slot) => slot.filter);
22287
+ writeFilters(target, slots);
21726
22288
  }
21727
22289
  /**
21728
22290
  * The filters kept for an entity.
@@ -21815,6 +22377,45 @@ function moveFilters(state, entity, view, before, kept) {
21815
22377
  clearFilters(view.object);
21816
22378
  applyFilters(view, slots, before);
21817
22379
  }
22380
+ /** What the walk of the switched filters reads, set before the walk: one at a time, no closure per frame. */
22381
+ const switched = {
22382
+ sync: void 0,
22383
+ kept: void 0,
22384
+ wrote: false
22385
+ };
22386
+ /**
22387
+ * Writes the enabled filters of one view again when one of them was switched on or off since they were handed to Pixi.
22388
+ *
22389
+ * @param slots - The filters of the view.
22390
+ * @param entity - The entity.
22391
+ */
22392
+ function applySwitchedOne(slots, entity) {
22393
+ const { sync, kept } = switched;
22394
+ const view = sync?.views.get(entity);
22395
+ if (view === void 0 || kept === void 0) return;
22396
+ const target = kept.filterTarget(view);
22397
+ if (holds(target, slots)) return;
22398
+ writeFilters(target, slots);
22399
+ switched.wrote = true;
22400
+ }
22401
+ /**
22402
+ * Hands Pixi the filters of every view whose filters were switched on or off since they were written (E3), before the
22403
+ * frame is drawn, so a switch is drawn in the frame it was made. No mark: the writer of a switch tells the renderer
22404
+ * with `invalidate` first. A write moves `passesVersion` on: the paddings the walks of `shown` read changed (E4).
22405
+ *
22406
+ * @param state - The sync branch of the plugin state.
22407
+ * @param kept - The kept module: where each view's filters hang.
22408
+ */
22409
+ function applySwitched(state, kept) {
22410
+ if (state.filters.size === 0) return;
22411
+ switched.sync = state;
22412
+ switched.kept = kept;
22413
+ switched.wrote = false;
22414
+ state.filters.forEach(applySwitchedOne);
22415
+ if (switched.wrote) state.passesVersion += 1;
22416
+ switched.sync = void 0;
22417
+ switched.kept = void 0;
22418
+ }
21818
22419
  /**
21819
22420
  * Forgets the filters and the filter area of an entity whose view left, only when the entity itself is gone. A live
21820
22421
  * entity keeps them for its next view, in this frame, as a `ui` element that swaps its shape for a
@@ -25057,6 +25658,7 @@ function applyHud(sctx, entity, view, factor) {
25057
25658
  if (view.hud === factor) return;
25058
25659
  markView(sctx, entity);
25059
25660
  view.hud = factor;
25661
+ sctx.ctx.state.sync.passesVersion += 1;
25060
25662
  if (ownsAlpha(view)) {
25061
25663
  applyAlpha(sctx, entity, view, componentAlpha(sctx, entity, view));
25062
25664
  return;
@@ -25812,6 +26414,7 @@ function dropView(sctx, entity) {
25812
26414
  state.views.delete(entity);
25813
26415
  if (state.filters.has(entity) || state.areas.has(entity)) state.viewlessEntries = true;
25814
26416
  state.orphans.delete(entity);
26417
+ state.held.delete(entity);
25815
26418
  state.entityOf.delete(view.object);
25816
26419
  state.viewOfTop.delete(view.wrapper ?? view.object);
25817
26420
  untrackKey(state, entity, view.textureKey);
@@ -26034,9 +26637,11 @@ function renderFrame(ctx, modules) {
26034
26637
  if (modules.viewport.applyPending()) {
26035
26638
  const root = modules.sync.root();
26036
26639
  modules.monitor.changed("resize");
26640
+ ctx.state.sync.passesVersion += 1;
26037
26641
  if (root !== void 0) modules.viewport.apply(root);
26038
26642
  modules.demand.whole("resize");
26039
26643
  }
26644
+ modules.sync.stepFilters();
26040
26645
  modules.sync.stepOverlays();
26041
26646
  modules.sync.drainOverlayEvents(modules.monitor);
26042
26647
  modules.kept.collect();
@@ -27796,8 +28401,9 @@ function emptyEntitySet$1() {
27796
28401
  return /* @__PURE__ */ new Set();
27797
28402
  }
27798
28403
  /**
27799
- * Where the walk of `shown` stopped for an entity's view, walked once between two sync passes: an entity with several
27800
- * endless loops asks once a frame (spec 26 R1.7.3). Nothing is allocated once the map has grown.
28404
+ * Where the walk of `shown` stopped for an entity's view, walked again only when `passesVersion` moved since (E4): an
28405
+ * entity with several endless loops is walked once, and the loops of a still board are answered from their records
28406
+ * (spec 26 R1.7.3). A record is made at an entity's first ask and written in place after.
27801
28407
  *
27802
28408
  * @param sctx - Domain context of the sync module.
27803
28409
  * @param entity - The entity a loop is about to write.
@@ -27807,9 +28413,16 @@ function unseenOf(sctx, entity) {
27807
28413
  const state = sctx.ctx.state.sync;
27808
28414
  if (state.root === void 0 || !state.views.has(entity)) return "other";
27809
28415
  const known = state.held.get(entity);
27810
- if (known !== void 0) return known;
28416
+ if (known !== void 0 && known.at === state.passesVersion) return known.answer;
27811
28417
  const answer = sctx.deps.demand.unseenBy(entity);
27812
- state.held.set(entity, answer);
28418
+ if (known === void 0) state.held.set(entity, {
28419
+ answer,
28420
+ at: state.passesVersion
28421
+ });
28422
+ else {
28423
+ known.answer = answer;
28424
+ known.at = state.passesVersion;
28425
+ }
27813
28426
  return answer;
27814
28427
  }
27815
28428
  /**
@@ -28200,7 +28813,6 @@ function createSyncApi(ctx, deps) {
28200
28813
  * view. A still frame (nothing changed, nothing waits) returns before any work and makes nothing.
28201
28814
  */
28202
28815
  const pass = () => {
28203
- if (state.held.size > 0) state.held.clear();
28204
28816
  if (still()) return;
28205
28817
  state.passesVersion += 1;
28206
28818
  if (state.removed.size > 0) {
@@ -28347,6 +28959,7 @@ function createSyncApi(ctx, deps) {
28347
28959
  layerContainer: (name) => layerContainer(state, name),
28348
28960
  layerOf: (entity) => layerOf(sctx, entity),
28349
28961
  stepOverlays: () => stepOverlays(sctx),
28962
+ stepFilters: () => applySwitched(state, deps.kept),
28350
28963
  drainOverlayEvents: (monitor) => drainOverlayEvents(sctx, monitor),
28351
28964
  warmStep: () => warmStep(sctx),
28352
28965
  onWarm: (fn) => {
@@ -28861,7 +29474,8 @@ function createModules$1(ctx) {
28861
29474
  const demand = createDemandApi(ctx, {
28862
29475
  host,
28863
29476
  views: ctx.state.sync.views,
28864
- hiddenBy
29477
+ hiddenBy,
29478
+ transparent: (node) => transparentNode(ctx.state.sync, node)
28865
29479
  });
28866
29480
  const built = {};
28867
29481
  const kept = createKeptApi(ctx, {
@@ -30994,7 +31608,8 @@ function same$1(stored, next) {
30994
31608
  * every frame all the same.
30995
31609
  *
30996
31610
  * A frame that moves every field writes the track's own patch, filled in place. A frame that
30997
- * leaves a field alone builds the patch of the fields it moves, so no other field is written.
31611
+ * leaves a field alone writes the track's patch of the fields it moves, kept for the next such
31612
+ * frame and built again only when the moved fields change, so no other field is written.
30998
31613
  *
30999
31614
  * @param actx - Domain context of the anim plugin.
31000
31615
  * @param track - The track to write, its normalised time in `clock[1]`.
@@ -31009,7 +31624,7 @@ function writeFields(actx, track, final) {
31009
31624
  }
31010
31625
  beginTrack(actx, track, stored);
31011
31626
  const owned = track.muted();
31012
- const { names, live, moved, values, seen, patch } = track;
31627
+ const { names, live, moved, values, seen } = track;
31013
31628
  let fields = 0;
31014
31629
  let count = 0;
31015
31630
  for (let slot = 0; slot < names.length; slot++) {
@@ -31029,27 +31644,57 @@ function writeFields(actx, track, final) {
31029
31644
  continue;
31030
31645
  }
31031
31646
  seen[slot] = next;
31032
- patch[field] = next;
31033
31647
  moved[slot] = 1;
31034
31648
  count++;
31035
31649
  }
31036
31650
  if (count === 0) return;
31037
- ecs.set(track.entity, track.component, count === fields ? patch : movedPatch(track));
31651
+ const written = count === fields ? track.patch : partialPatch(track);
31652
+ fillPatch(track, written);
31653
+ ecs.set(track.entity, track.component, written);
31038
31654
  }
31039
31655
  /**
31040
- * The patch of a frame that moved only some of the fields of a track: those fields, at the values
31041
- * the frame put into the track's patch.
31656
+ * Puts the values of the moved slots into a patch of the track: each moved field gets what the
31657
+ * frame left in `values` for it.
31042
31658
  *
31043
31659
  * @param track - The track that writes.
31044
- * @returns A new patch of the moved fields only.
31660
+ * @param written - Its full patch, or its patch of the moved fields.
31045
31661
  */
31046
- function movedPatch(track) {
31047
- const written = {};
31662
+ function fillPatch(track, written) {
31663
+ const { names, moved, values } = track;
31664
+ for (let slot = 0; slot < names.length; slot++) {
31665
+ const field = names[slot];
31666
+ if (moved[slot] === 1 && field !== void 0) written[field] = values[slot] ?? 0;
31667
+ }
31668
+ }
31669
+ /**
31670
+ * The patch of a frame that moved only some of the fields of a track: the one the track keeps,
31671
+ * when the frame moved the same fields as the frame it was built for, otherwise a new one.
31672
+ *
31673
+ * @param track - The track that writes, its `moved` flags set by this frame.
31674
+ * @returns The patch of exactly the moved fields; their values are filled after.
31675
+ */
31676
+ function partialPatch(track) {
31677
+ const { moved, partialMoved, partial } = track;
31678
+ if (partial === void 0) return keepPartial(track);
31679
+ for (let slot = 0; slot < moved.length; slot++) if (moved[slot] !== partialMoved[slot]) return keepPartial(track);
31680
+ return partial;
31681
+ }
31682
+ /**
31683
+ * Builds the patch of the fields the frame moved and keeps it on the track, with the `moved`
31684
+ * flags it was built for. Runs only when the moved fields change.
31685
+ *
31686
+ * @param track - The track that writes.
31687
+ * @returns The new patch, its values still to fill.
31688
+ */
31689
+ function keepPartial(track) {
31690
+ const built = {};
31048
31691
  for (let slot = 0; slot < track.names.length; slot++) {
31049
31692
  const field = track.names[slot];
31050
- if (track.moved[slot] === 1 && field !== void 0) written[field] = track.patch[field];
31693
+ if (track.moved[slot] === 1 && field !== void 0) built[field] = 0;
31051
31694
  }
31052
- return written;
31695
+ track.partialMoved.set(track.moved);
31696
+ track.partial = built;
31697
+ return built;
31053
31698
  }
31054
31699
  /**
31055
31700
  * Releases the field bookkeeping of a track that is ending: the fields it owned, its own offsets
@@ -31289,7 +31934,9 @@ function startTrack$1(actx, entity, component, to, options, muted, driven = fals
31289
31934
  masked: noMaskedFields(),
31290
31935
  keep,
31291
31936
  reveals: revealsOf(component, target),
31292
- patch: { ...target }
31937
+ patch: { ...target },
31938
+ partial: void 0,
31939
+ partialMoved: new Uint8Array(names.length)
31293
31940
  };
31294
31941
  actx.state.nextId += 1;
31295
31942
  if (track.ended) return track;
@@ -35123,9 +35770,24 @@ function overTargetOf(ctx, node, run) {
35123
35770
  return takePending(ctx);
35124
35771
  }
35125
35772
  /**
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
+ /**
35126
35788
  * Builds the scene in one synchronous block: the layers, then the projections of the old scene
35127
- * out, then the ones of the new scene in, then the list the scene names to `assets`, then the
35128
- * frame loop out of its idle cap, and only then the event.
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.
35129
35791
  *
35130
35792
  * @param ctx - Domain context of the plugin.
35131
35793
  * @param scene - The scene whose bundles are there.
@@ -35140,6 +35802,7 @@ function apply(ctx, scene, named) {
35140
35802
  if (previous !== void 0) projection.unmount(previous.projections);
35141
35803
  projection.mount(scene.projections, state.owner);
35142
35804
  state.current = scene.id;
35805
+ holdOwner(ctx, scene.id);
35143
35806
  state.named = named;
35144
35807
  ctx.deps.assets.sceneBundles(named);
35145
35808
  ctx.deps.time.wake();
@@ -35253,22 +35916,16 @@ function withDeps$5(ctx) {
35253
35916
  };
35254
35917
  }
35255
35918
  /**
35256
- * Records which feature declared which scene, so a duplicate id can name both of them.
35257
- *
35258
- * @returns An empty table of scene id to feature name.
35259
- */
35260
- function emptyOwners$1() {
35261
- return /* @__PURE__ */ new Map();
35262
- }
35263
- /**
35264
- * Reads the `scenes` key of every registered feature into the registry. Features register in
35265
- * their own `onInit`, which runs before this `onStart`, so every scene of the game is here.
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.
35266
35923
  *
35267
35924
  * @param ctx - Domain context of the plugin.
35268
35925
  * @throws {Error} When two features declare the same scene id.
35269
35926
  */
35270
35927
  function readScenes(ctx) {
35271
- const owners = emptyOwners$1();
35928
+ const owners = ctx.state.owners;
35272
35929
  for (const feature of ctx.deps.flow.features.all()) {
35273
35930
  const list = feature.description.scenes;
35274
35931
  if (!Array.isArray(list)) continue;
@@ -35299,9 +35956,9 @@ function startScenes(ctx) {
35299
35956
  scenes.state.teardown = scenes.deps.flow.onEnter("scene", (node, run) => enterScene(scenes, node, run));
35300
35957
  }
35301
35958
  /**
35302
- * Removes the `onEnter` callback in `onStop` and forgets every scene and the bundles the mounted
35303
- * one named. Nothing is unmounted: a
35304
- * teardown context may not call another plugin, and `world.onStop` clears every entity anyway.
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.
35305
35962
  *
35306
35963
  * @param teardown - What the teardown context carries.
35307
35964
  * @param teardown.state - The plugin state.
@@ -35316,6 +35973,8 @@ function stopScenes(teardown) {
35316
35973
  state.teardown?.();
35317
35974
  state.teardown = void 0;
35318
35975
  state.scenes.clear();
35976
+ state.owners.clear();
35977
+ state.held = void 0;
35319
35978
  state.current = void 0;
35320
35979
  state.pending = void 0;
35321
35980
  state.named = [];
@@ -35332,6 +35991,15 @@ function emptyRegistry$1() {
35332
35991
  return /* @__PURE__ */ new Map();
35333
35992
  }
35334
35993
  /**
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
+ /**
35335
36003
  * Creates the one owner every mount of this plugin carries. One is enough: a single scene is
35336
36004
  * mounted at a time and `unmount` is what flushes.
35337
36005
  *
@@ -35344,8 +36012,8 @@ function sceneOwner() {
35344
36012
  };
35345
36013
  }
35346
36014
  /**
35347
- * Creates the initial scenes state: nothing declared, nothing mounted, nothing pending. The
35348
- * registry is filled in `onStart` from the feature descriptions.
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.
35349
36017
  *
35350
36018
  * @returns A fresh state, owned by one app.
35351
36019
  */
@@ -35356,7 +36024,9 @@ function createScenesState() {
35356
36024
  pending: void 0,
35357
36025
  named: [],
35358
36026
  owner: sceneOwner(),
35359
- teardown: void 0
36027
+ teardown: void 0,
36028
+ owners: emptyOwners$1(),
36029
+ held: void 0
35360
36030
  };
35361
36031
  }
35362
36032
  //#endregion
@@ -52800,6 +53470,16 @@ function entitySet(entities) {
52800
53470
  return new Set(entities);
52801
53471
  }
52802
53472
  /**
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
+ /**
52803
53483
  * Builds the reconcile half of the jsx module: the two systems of the frame and the root
52804
53484
  * bookkeeping around them.
52805
53485
  *
@@ -53404,6 +54084,7 @@ function createReconciler(ctx, modules, fields) {
53404
54084
  if (root === void 0) continue;
53405
54085
  if (root.element !== void 0 && state.elements.has(root.element)) continue;
53406
54086
  state.removing.delete(entity);
54087
+ root.held?.();
53407
54088
  state.roots.delete(entity);
53408
54089
  state.byKey.delete(entity);
53409
54090
  ecs.despawn(entity);
@@ -53821,15 +54502,21 @@ function createReconciler(ctx, modules, fields) {
53821
54502
  if (element?.live === true && root.slot === void 0) ecs.set(element.entity, Order, { value: order });
53822
54503
  }
53823
54504
  /**
53824
- * Reconciles one dirty root: reads its current tree and projection name, then diffs it. The root
53825
- * stays dirty while the layout engine is not loaded, and a failure is logged for this root only.
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.
53826
54510
  *
53827
54511
  * @param root - A root with work this frame.
53828
54512
  */
53829
54513
  function reconcileDirtyRoot(root) {
53830
54514
  const tree = ecs.get(root.entity, Tree);
53831
54515
  const place = ctx.deps.world.projection.keyOf(root.entity);
53832
- if (place !== void 0) root.name = place.projection;
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
+ }
53833
54520
  if (tree !== void 0) root.tree = tree.node;
53834
54521
  refreshRootOrder(root);
53835
54522
  root.dirty = !modules.layout.loaded();
@@ -53906,7 +54593,10 @@ function createReconciler(ctx, modules, fields) {
53906
54593
  /**
53907
54594
  * Records a root: a projection view that returned a tree, or a popup this plugin spawned. The
53908
54595
  * `Tree` hook and the popup handler both call it, in either order, so a second call fills in
53909
- * what the first one could not know.
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.
53910
54600
  *
53911
54601
  * @param entity - The entity that carries `Tree`.
53912
54602
  * @param name - The projection name, or the component name of a popup.
@@ -53922,6 +54612,7 @@ function createReconciler(ctx, modules, fields) {
53922
54612
  known.popup = popup;
53923
54613
  known.name = name;
53924
54614
  known.cause = "change";
54615
+ known.held ??= holdFor(ctx, state.featureOfComponent.get(name));
53925
54616
  }
53926
54617
  return;
53927
54618
  }
@@ -53945,7 +54636,8 @@ function createReconciler(ctx, modules, fields) {
53945
54636
  cause: popup === void 0 ? void 0 : "change",
53946
54637
  slot: void 0,
53947
54638
  slotRect: void 0,
53948
- pending: false
54639
+ pending: false,
54640
+ held: popup === void 0 ? void 0 : holdFor(ctx, state.featureOfComponent.get(name))
53949
54641
  });
53950
54642
  }
53951
54643
  /**
@@ -56180,14 +56872,45 @@ function isComponent(entry) {
56180
56872
  return typeof entry === "function" && entry.isUiComponent === true;
56181
56873
  }
56182
56874
  /**
56183
- * Fills the component registry from the `ui` key of every feature, in feature order.
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.
56184
56894
  *
56185
56895
  * @param ctx - Domain context of the ui plugin.
56186
56896
  * @param jsx - The jsx module, which owns the registry.
56187
56897
  * @throws {Error} When two features define the same component name.
56188
56898
  */
56189
56899
  function registerComponents(ctx, jsx) {
56190
- for (const feature of ctx.deps.flow.features.all()) for (const entry of feature.description.ui ?? []) if (isComponent(entry)) jsx.register(entry);
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
+ }
56191
56914
  }
56192
56915
  /**
56193
56916
  * Opens the two systems of phase `layout`, the seven world hooks of `Tree`, `Box`, `Pressed` and
@@ -56267,6 +56990,8 @@ function stopUi(state) {
56267
56990
  state.layout.scrolling = void 0;
56268
56991
  state.layout.iconLine = void 0;
56269
56992
  state.jsx.components.clear();
56993
+ state.jsx.featureOfComponent.clear();
56994
+ state.jsx.featureOfProjection.clear();
56270
56995
  state.jsx.roots.clear();
56271
56996
  state.jsx.elements.clear();
56272
56997
  state.jsx.byIdentity.clear();
@@ -56662,7 +57387,9 @@ function createJsxState() {
56662
57387
  written: 0,
56663
57388
  debugTaps: [],
56664
57389
  debugWarned: false,
56665
- overlays: emptySet()
57390
+ overlays: emptySet(),
57391
+ featureOfComponent: emptyMap$1(),
57392
+ featureOfProjection: emptyMap$1()
56666
57393
  };
56667
57394
  }
56668
57395
  //#endregion
@@ -57408,6 +58135,18 @@ function assertFeatures(name, children) {
57408
58135
  for (const [index, child] of children.entries()) if (typeof child?.logicOnly !== "object" || !Array.isArray(child.features)) throw notAFeature(`Feature "${name}"`, `features[${index}]`);
57409
58136
  }
57410
58137
  /**
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
+ /**
57411
58150
  * Builds the plugin of one feature: it depends on `flow`, checks in `onInit` that the app composes
57412
58151
  * every declared sub-feature, then registers its description, before any plugin above reads the
57413
58152
  * registry in `onStart`.
@@ -57446,12 +58185,14 @@ function featurePluginOf(name, description, children) {
57446
58185
  * same plugin built from the V1 keys of the description, for headless tests. A feature that
57447
58186
  * declares `features` is a parent: its `onInit` throws when one of them is not composed. The
57448
58187
  * 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`).
57449
58189
  *
57450
58190
  * @param name - Feature name. Shares the namespace with plugin names.
57451
58191
  * @param description - What the feature brings: nodes, flows, slot contributions, sub-features.
57452
58192
  * @returns The feature plugin, with its `logicOnly` twin.
57453
- * @throws {Error} When the name is reserved or belongs to an engine plugin, and for an entry of
57454
- * `features` that `defineFeature` did not make.
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"`.
57455
58196
  * @example
57456
58197
  * ```ts
57457
58198
  * // The reward popup is a feature: the main flow only declares slot("afterOrder").
@@ -57466,6 +58207,7 @@ function featurePluginOf(name, description, children) {
57466
58207
  */
57467
58208
  function defineFeature(name, description) {
57468
58209
  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);
57469
58211
  const children = Object.freeze([...description.features ?? []]);
57470
58212
  assertFeatures(name, children);
57471
58213
  const logicOnly = Object.assign(featurePluginOf(name, logicKeysOf(description), children), { features: Object.freeze(children.map((child) => child.logicOnly)) });
@@ -57947,6 +58689,168 @@ function defineEffect(kind, options = {}) {
57947
58689
  });
57948
58690
  }
57949
58691
  //#endregion
58692
+ //#region src/plugins/anim/tween/bezier.ts
58693
+ /**
58694
+ * @file anim/tween — the CSS timing function `cubic-bezier(x1, y1, x2, y2)` as an easing. The
58695
+ * curve is solved once, into a table of its progress at 512 even steps of time; a frame reads two
58696
+ * cells and draws a line between them. Where a line strays from the curve (the curve leaves an end
58697
+ * vertically), that step is solved again at each call, inside the bracket the table keeps. Either
58698
+ * way a call allocates nothing.
58699
+ */
58700
+ /**
58701
+ * How many even steps of time the table splits 0…1 into. With 512, a line strays at most 1e-5
58702
+ * from any curve that keeps a finite slope (the CSS keywords, an overshoot both ways), so only a
58703
+ * curve that leaves an end vertically solves any step at a call; 8 KB per curve.
58704
+ */
58705
+ const STEPS = 512;
58706
+ /**
58707
+ * How far the line of a step may stray from the curve at the step's middle before that step is
58708
+ * solved at each call instead: a fifth of the 1e-4 the factory promises, so the rest of the step,
58709
+ * where the line may stray a little more, stays inside the promise too (2e-5 measured).
58710
+ */
58711
+ const STRAY = 2e-5;
58712
+ /** Halvings of a solve: 2^-48 of the bracket, far below what a double of progress shows. */
58713
+ const HALVINGS = 48;
58714
+ /**
58715
+ * One coordinate of a cubic curve from 0 to 1 with the two inner control values. Pure.
58716
+ *
58717
+ * @param along - The curve parameter, 0 to 1.
58718
+ * @param first - The first control value.
58719
+ * @param second - The second control value.
58720
+ * @returns The coordinate at `along`.
58721
+ * @example
58722
+ * ```ts
58723
+ * coordinate(0.5, 1 / 3, 2 / 3); // 0.5: the middle of a straight line
58724
+ * ```
58725
+ */
58726
+ function coordinate(along, first, second) {
58727
+ const back = 1 - along;
58728
+ return 3 * back * back * along * first + 3 * back * along * along * second + along * along * along;
58729
+ }
58730
+ /**
58731
+ * The curve parameter whose x is `time`, found by halving a bracket of parameters. The x of the
58732
+ * curve never falls while x1 and x2 lie in 0…1, so halving always finds it. Pure.
58733
+ *
58734
+ * @param time - The x to reach.
58735
+ * @param x1 - The x of the first control point.
58736
+ * @param x2 - The x of the second control point.
58737
+ * @param low - A parameter whose x is at most `time`.
58738
+ * @param high - A parameter whose x is at least `time`.
58739
+ * @returns The parameter.
58740
+ * @example
58741
+ * ```ts
58742
+ * parameterAt(0.25, 0, 1, 0, 1); // 0.25: on a straight line the parameter is the time
58743
+ * ```
58744
+ */
58745
+ function parameterAt(time, x1, x2, low, high) {
58746
+ let from = low;
58747
+ let to = high;
58748
+ for (let step = 0; step < HALVINGS; step++) {
58749
+ const middle = (from + to) / 2;
58750
+ if (coordinate(middle, x1, x2) < time) from = middle;
58751
+ else to = middle;
58752
+ }
58753
+ return (from + to) / 2;
58754
+ }
58755
+ /**
58756
+ * Solves a curve into its table: the parameter and the progress at every even step of time, and
58757
+ * the steps whose line strays from the curve at their middle.
58758
+ *
58759
+ * @param points - `[x1, y1, x2, y2]`, x1 and x2 in 0…1.
58760
+ * @returns The table.
58761
+ */
58762
+ function solveTable(points) {
58763
+ const [x1 = 0, y1 = 0, x2 = 0, y2 = 0] = points;
58764
+ const params = /* @__PURE__ */ new Float64Array(513);
58765
+ const progress = /* @__PURE__ */ new Float64Array(513);
58766
+ const solved = new Uint8Array(STEPS);
58767
+ for (let index = 1; index < STEPS; index++) {
58768
+ params[index] = parameterAt(index / STEPS, x1, x2, 0, 1);
58769
+ progress[index] = coordinate(params[index] ?? 0, y1, y2);
58770
+ }
58771
+ params[STEPS] = 1;
58772
+ progress[STEPS] = 1;
58773
+ for (let index = 0; index < STEPS; index++) {
58774
+ const middle = parameterAt((index + .5) / STEPS, x1, x2, params[index] ?? 0, params[index + 1] ?? 1);
58775
+ const line = ((progress[index] ?? 0) + (progress[index + 1] ?? 0)) / 2;
58776
+ if (Math.abs(coordinate(middle, y1, y2) - line) > STRAY) solved[index] = 1;
58777
+ }
58778
+ return {
58779
+ points,
58780
+ params,
58781
+ progress,
58782
+ solved
58783
+ };
58784
+ }
58785
+ /**
58786
+ * The cell a solved step passes its time in and its progress out: a double handed to or returned
58787
+ * from a call V8 does not inline is boxed, a cell is not.
58788
+ */
58789
+ const SOLVED = /* @__PURE__ */ new Float64Array(1);
58790
+ /**
58791
+ * The progress of a curve at one time: exactly 0 up to the start and 1 from the end on, a line
58792
+ * between two cells of the table in between, or the curve itself on a step whose line strays.
58793
+ *
58794
+ * @param table - The curve's table.
58795
+ * @param time - The time.
58796
+ * @returns The progress.
58797
+ */
58798
+ function progressAt(table, time) {
58799
+ if (time <= 0) return 0;
58800
+ if (time >= 1) return 1;
58801
+ const at = time * STEPS;
58802
+ const index = Math.floor(at);
58803
+ if (table.solved[index] === 1) {
58804
+ SOLVED[0] = time;
58805
+ solveStep(table, index);
58806
+ return SOLVED[0] ?? 0;
58807
+ }
58808
+ const low = table.progress[index] ?? 0;
58809
+ return low + ((table.progress[index + 1] ?? 1) - low) * (at - index);
58810
+ }
58811
+ /**
58812
+ * The progress on a step whose line strays from the curve: the curve solved inside the bracket of
58813
+ * parameters the table keeps for that step. It reads the time from `SOLVED[0]` and leaves the
58814
+ * progress there. Kept apart so the common call stays small.
58815
+ *
58816
+ * @param table - The curve's table.
58817
+ * @param index - The step.
58818
+ */
58819
+ function solveStep(table, index) {
58820
+ const points = table.points;
58821
+ const along = parameterAt(SOLVED[0] ?? 0, points[0] ?? 0, points[2] ?? 0, table.params[index] ?? 0, table.params[index + 1] ?? 1);
58822
+ SOLVED[0] = coordinate(along, points[1] ?? 0, points[3] ?? 0);
58823
+ }
58824
+ /**
58825
+ * The CSS timing function `cubic-bezier(x1, y1, x2, y2)` as an easing a tween, a keyframe segment
58826
+ * or a motion takes for `ease`. The curve is solved once, here, into a table; a frame reads it, so
58827
+ * an eased time costs a few reads and allocates nothing, and the progress stays within 1e-4 of the
58828
+ * exact curve. It is exactly 0 up to the start and exactly 1 from the end on, and may leave 0…1 on
58829
+ * the way (an overshoot). Make each curve once, at module level, and share it.
58830
+ *
58831
+ * @param x1 - The x of the first control point, 0 to 1.
58832
+ * @param y1 - The y of the first control point; below 0 or above 1 overshoots.
58833
+ * @param x2 - The x of the second control point, 0 to 1.
58834
+ * @param y2 - The y of the second control point; below 0 or above 1 overshoots.
58835
+ * @returns The curve: normalised time in, eased progress out.
58836
+ * @throws {Error} When x1 or x2 lies outside 0…1: the curve would be no function of time.
58837
+ * @example
58838
+ * ```ts
58839
+ * // The design's arrival curve, made once and named by every tween that arrives.
58840
+ * const arrive = cubicBezier(0.22, 0.61, 0.36, 1);
58841
+ * arrive(0.5); // 0.873 (to 1e-4)
58842
+ * const drop = defineAnimation("card.drop", {
58843
+ * slots: { card: type<Target>() },
58844
+ * build: ({ card }) => sequence(tween(card, Transform, { y: 40 }, { ms: 300, ease: arrive }))
58845
+ * });
58846
+ * ```
58847
+ */
58848
+ function cubicBezier(x1, y1, x2, y2) {
58849
+ if (!(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1)) throw new Error(`[game] cubicBezier needs x1 and x2 between 0 and 1, got ${x1} and ${x2}.\n The x of a control point is a time; y1 and y2 may leave 0…1 for an overshoot.`);
58850
+ const table = solveTable(Float64Array.of(x1, y1, x2, y2));
58851
+ return (t) => progressAt(table, t);
58852
+ }
58853
+ //#endregion
57950
58854
  //#region src/index.ts
57951
58855
  /**
57952
58856
  * The `@moku-labs/game` package — 2D puzzle game engine on Moku Core.
@@ -58155,4 +59059,4 @@ const screen = [
58155
59059
  uiPlugin
58156
59060
  ];
58157
59061
  //#endregion
58158
- export { parallel as $, SHADER_WHATS as $t, types_exports$12 as A, Blur as At, DebugTrigger as B, assetsPlugin as Bt, types_exports$5 as C, Traced as Ct, types_exports$9 as D, effectsPlugin as Dt, types_exports$8 as E, messageDuration as Et, types_exports$17 as F, Outline as Ft, textPlugin as G, rendererPlugin as Gt, popup as H, AnimPlayer as Ht, uiPlugin as I, defineFilter as It, defineAnimation as J, MONITOR_SECTIONS as Jt, platformPlugin as K, PASS_KINDS as Kt, resolve as L, debugPlugin as Lt, types_exports$14 as M, Displacement as Mt, types_exports$15 as N, Glow as Nt, types_exports$10 as O, Emitter as Ot, types_exports$16 as P, Noise as Pt, mark as Q, PROBLEM_RULES as Qt, defineMotion as R, audioPlugin as Rt, types_exports$4 as S, Traceable as St, types_exports$7 as T, messageArgument as Tt, defineTokens as U, Animation as Ut, LocalWrite as V, animPlugin as Vt, defineStyle as W, Frames as Wt, frames as X, PERF_GAP_BUCKETS as Xt, external as Y, PERF_CAUSES as Yt, haptic as Z, PERF_LOG_VERSION as Zt, load as _, Pressable as _t, defineEffect as a, slot as an, spawned as at, types_exports$2 as b, Tappable as bt, schedule as c, modelPlugin as cn, use as ct, tr as d, timePlugin as dn, Draggable as dt, DEMAND_REASONS as en, play$1 as et, defineFeature as f, clockPlugin as fn, DropTarget as ft, defineBundles as g, PointerOver as gt, music as h, Pointer as ht, screen as i, exit as in, spawn as it, types_exports$13 as j, ColorMatrix as jt, types_exports$11 as k, Alpha as kt, projection as l, SaveUnreadableError as ln, wait as lt, defineEmitter as m, Hovered as mt, createPlugin as n, flowPlugin as nn, set as nt, guide as o, to as on, stagger as ot, flattenFeatures as p, Held as pt, HAPTIC_KINDS as q, frameAt as qt, defineGame as r, refuseStart as rn, sfx as rt, hint as s, type as sn, tween as st, createApp as t, worldPlugin as tn, sequence as tt, defineScene as u, lifecyclePlugin as un, inputPlugin as ut, types_exports as v, Pressed as vt, types_exports$6 as w, i18nPlugin as wt, types_exports$3 as x, Touchable as xt, types_exports$1 as y, Swipeable as yt, Box as z, scenesPlugin as zt };
59062
+ export { mark as $, PROBLEM_RULES as $t, types_exports$11 as A, Alpha as At, Box as B, scenesPlugin as Bt, types_exports$4 as C, Traceable as Ct, types_exports$8 as D, messageDuration as Dt, types_exports$7 as E, messageArgument as Et, types_exports$16 as F, Noise as Ft, defineStyle as G, Frames as Gt, LocalWrite as H, animPlugin as Ht, types_exports$17 as I, Outline as It, HAPTIC_KINDS as J, frameAt as Jt, textPlugin as K, rendererPlugin as Kt, uiPlugin as L, defineFilter as Lt, types_exports$13 as M, ColorMatrix as Mt, types_exports$14 as N, Displacement as Nt, types_exports$9 as O, effectsPlugin as Ot, types_exports$15 as P, Glow as Pt, haptic as Q, PERF_LOG_VERSION as Qt, resolve as R, debugPlugin as Rt, types_exports$3 as S, Touchable as St, types_exports$6 as T, i18nPlugin as Tt, popup as U, AnimPlayer as Ut, DebugTrigger as V, assetsPlugin as Vt, defineTokens as W, Animation as Wt, external as X, PERF_CAUSES as Xt, defineAnimation as Y, MONITOR_SECTIONS as Yt, frames as Z, PERF_GAP_BUCKETS as Zt, defineBundles as _, PointerOver as _t, cubicBezier as a, exit as an, spawn as at, types_exports$1 as b, Swipeable as bt, hint as c, type as cn, tween as ct, defineScene as d, lifecyclePlugin as dn, inputPlugin as dt, SHADER_WHATS as en, parallel as et, tr as f, timePlugin as fn, Draggable as ft, music as g, Pointer as gt, defineEmitter as h, Hovered as ht, screen as i, refuseStart as in, sfx as it, types_exports$12 as j, Blur as jt, types_exports$10 as k, Emitter as kt, schedule as l, modelPlugin as ln, use as lt, flattenFeatures as m, Held as mt, createPlugin as n, worldPlugin as nn, sequence as nt, defineEffect as o, slot as on, spawned as ot, defineFeature as p, clockPlugin as pn, DropTarget as pt, platformPlugin as q, PASS_KINDS as qt, defineGame as r, flowPlugin as rn, set as rt, guide as s, to as sn, stagger as st, createApp as t, DEMAND_REASONS as tn, play$1 as tt, projection as u, SaveUnreadableError as un, wait as ut, load as v, Pressable as vt, types_exports$5 as w, Traced as wt, types_exports$2 as x, Tappable as xt, types_exports as y, Pressed as yt, defineMotion as z, audioPlugin as zt };