@moku-labs/game 0.15.39 → 0.15.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/app/page.d.mts +1 -1
  3. package/dist/app/system.d.mts +3 -3
  4. package/dist/app.d.mts +3 -3
  5. package/dist/app.mjs +1 -1
  6. package/dist/assets.d.mts +1 -1
  7. package/dist/{capture-PTJXDpxK.mjs → capture-BtS6_oWr.mjs} +61 -1
  8. package/dist/{commands-CMFdiDcg.d.mts → commands-DLk06fiL.d.mts} +5 -5
  9. package/dist/control.d.mts +4 -4
  10. package/dist/control.mjs +1 -1
  11. package/dist/{define-D27mfOhZ.d.mts → define-CgU4dz0e.d.mts} +2 -2
  12. package/dist/{headless-CRqMxzqv.d.mts → headless-1e3gEF1Y.d.mts} +1 -1
  13. package/dist/{headless-Bxxl_mQX.mjs → headless-CCiUwvlp.mjs} +1 -1
  14. package/dist/{index-DFAjLwxi.d.mts → index-D12s9KZ2.d.mts} +19 -7
  15. package/dist/index.d.mts +5 -5
  16. package/dist/index.mjs +2 -2
  17. package/dist/inspect.d.mts +6 -6
  18. package/dist/jsx-dev-runtime.d.mts +1 -1
  19. package/dist/jsx-runtime.d.mts +1 -1
  20. package/dist/{run-G4m1APMv.mjs → run-8s7kh2QD.mjs} +2 -2
  21. package/dist/{runtime-CbLZyDXj.d.mts → runtime-Bpg0WmM8.d.mts} +1 -1
  22. package/dist/{src-B2vblgmb.mjs → src-CWVwT9UM.mjs} +1858 -721
  23. package/dist/testing.d.mts +4 -4
  24. package/dist/testing.mjs +2 -2
  25. package/dist/{types-54KzmAA6.d.mts → types-B-o8Ul1v.d.mts} +2 -2
  26. package/dist/{types-0Br0SjTB.d.mts → types-CFEtGENK.d.mts} +1 -1
  27. package/dist/{types-H1FiWmKa.d.mts → types-DQ9VHXun.d.mts} +4 -4
  28. package/dist/{types-C_dk-lXz.d.mts → types-DrkR03nL.d.mts} +413 -31
  29. package/dist/{types-CGPWYbZO.d.mts → types-gQATSj41.d.mts} +2 -2
  30. package/dist/{visual-DjTJ02c3.d.mts → visual-pHKIFBSQ.d.mts} +6 -6
  31. package/dist/visual.d.mts +1 -1
  32. package/dist/visual.mjs +1 -1
  33. package/llms.txt +14 -4
  34. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
2
2
  import { r as systemSource, t as memory } from "./memory-CWlqhS7l.mjs";
3
3
  import { a as finishDrafts, i as enableDraftPatches, n as deepFreeze$1, o as openDrafts, r as dropDrafts } from "./drafts-BawpSaVp.mjs";
4
- import { _ as pushEntry, c as collectFlows, d as findNode, f as framePath, g as compact, h as slotNames, i as servePaused, l as describeGraph, m as isRestCheckpoint, n as captureFrame, o as epochOf, p as graphHash, r as serveCaptures, s as pageNow$1, t as cancelCaptures } from "./capture-PTJXDpxK.mjs";
4
+ import { _ as compact, c as collectFlows, d as findNode, f as framePath, g as slotNames, h as restlessCapOf, i as servePaused, l as describeGraph, m as isRestCheckpoint, n as captureFrame, o as epochOf, p as graphHash, r as serveCaptures, s as pageNow$1, t as cancelCaptures, v as pushEntry } from "./capture-BtS6_oWr.mjs";
5
5
  import { $ as component, A as localPoseOf, B as OverlayLevel, C as builtInStyles, D as textFor, E as readStyle, F as rootPoseOf, G as arcDefaults, H as Shape, I as Display, J as Exiting, K as componentsFor, L as HudPart, M as poseUnder, N as rootPointOf, O as applyPose, P as rootPoseInto, Q as Tree, R as NineSlice, U as Sprite, V as Parent, W as Transform, X as Now, Y as Layer, Z as Order, _ as uiFor, a as visualOf$1, b as Countdown, c as Covered, d as LocalWrite, f as Scroll, h as UiCounters, i as samePose, it as tag, j as parentOf$1, k as isLeaving, l as DebugTrigger, m as UI_OWNER, n as drawnRect, nt as system, o as visualRectOf, p as ScrollState, r as fitScaleOf, rt as systemsFor, s as Box, t as CONTENT$1, tt as resource, u as Escapable, x as Text, z as Overlay } from "./visual-ClVRMUAi.mjs";
6
6
  import { a as isDev } from "./dev-BNndV-hU.mjs";
7
7
  import { n as isComponentDefinition } from "./component-DGg5DqKK.mjs";
@@ -2444,7 +2444,7 @@ var SaveUnreadableError = class extends Error {
2444
2444
  * isRecord(["key"]); // false: an array is not a record
2445
2445
  * ```
2446
2446
  */
2447
- function isRecord$4(value) {
2447
+ function isRecord$5(value) {
2448
2448
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2449
2449
  }
2450
2450
  /**
@@ -2459,7 +2459,7 @@ function isRecord$4(value) {
2459
2459
  * ```
2460
2460
  */
2461
2461
  function readStreams(value) {
2462
- if (!isRecord$4(value)) return void 0;
2462
+ if (!isRecord$5(value)) return void 0;
2463
2463
  const streams = {};
2464
2464
  for (const [id, state] of Object.entries(value)) {
2465
2465
  if (typeof state !== "number") return void 0;
@@ -2483,9 +2483,9 @@ function readStreams(value) {
2483
2483
  * ```
2484
2484
  */
2485
2485
  function readSaveDoc(state, savedVersion, schemaVersion) {
2486
- const record = isRecord$4(state) ? state : void 0;
2486
+ const record = isRecord$5(state) ? state : void 0;
2487
2487
  const branch = record ? record.rng : void 0;
2488
- const rng = isRecord$4(branch) ? branch : void 0;
2488
+ const rng = isRecord$5(branch) ? branch : void 0;
2489
2489
  const streams = readStreams(rng ? rng.streams : void 0);
2490
2490
  if (!record || record.player === void 0 || !rng || typeof rng.seed !== "number" || !streams) throw new SaveUnreadableError(savedVersion, schemaVersion, /* @__PURE__ */ new Error("[game] The save is not a document of the shape { player, rng }.\n Restore the backup or start a new save."));
2491
2491
  return {
@@ -3673,7 +3673,7 @@ function nextNodes(flow, name) {
3673
3673
  */
3674
3674
  function targetProblem(flow, name, outcome, target) {
3675
3675
  if (typeof target !== "string" && target.kind === "exit") {
3676
- if (target.outcome in flow.outcomes) return void 0;
3676
+ if (Object.hasOwn(flow.outcomes, target.outcome)) return void 0;
3677
3677
  return `[game] Flow "${flow.id}": exit("${target.outcome}") of node "${name}" names no outcome of the flow.\n Declare outcomes.${target.outcome} on flow "${flow.id}".`;
3678
3678
  }
3679
3679
  const next = typeof target === "string" ? target : target.target;
@@ -3743,7 +3743,7 @@ function nodeProblems(flow) {
3743
3743
  if (entry.kind === "slot" && !entry.name) problems.push(`[game] Flow "${flow.id}": the slot node "${name}" has no slot name.\n Give it a name: slot("${name}").`);
3744
3744
  if (entry.kind !== "node") continue;
3745
3745
  for (const type of entry.inbox) {
3746
- if (type in entry.outcomes) continue;
3746
+ if (Object.hasOwn(entry.outcomes, type)) continue;
3747
3747
  problems.push(`[game] Flow "${flow.id}": inbox type "${type}" of node "${name}" is not an outcome.\n Add outcomes.${type} to node "${name}".`);
3748
3748
  }
3749
3749
  if (entry.rest && !entry.run && Object.keys(entry.outcomes).length === 0) problems.push(`[game] Flow "${flow.id}": rest node "${name}" has neither outcomes nor a run body.\n Declare outcomes or add a run body.`);
@@ -3889,6 +3889,138 @@ function featureProblems(flows, features) {
3889
3889
  return problems;
3890
3890
  }
3891
3891
  /**
3892
+ * Names the outcomes of a table besides `done`: what a slot declares, or what a contributed flow
3893
+ * may leave its slot on.
3894
+ *
3895
+ * @param outcomes - The outcome table of a slot or a flow.
3896
+ * @returns The outcome names other than `done`, in declaration order.
3897
+ * @example
3898
+ * ```ts
3899
+ * declaredOutcomes({ done: type(), shop: type() }); // ["shop"]
3900
+ * ```
3901
+ */
3902
+ function declaredOutcomes(outcomes) {
3903
+ return Object.keys(outcomes).filter((name) => name !== "done");
3904
+ }
3905
+ /**
3906
+ * Tells whether two lists hold the same names, in any order.
3907
+ *
3908
+ * @param first - One list of names.
3909
+ * @param second - The other list.
3910
+ * @returns True when every name of one is in the other and the lengths match.
3911
+ * @example
3912
+ * ```ts
3913
+ * sameNames(["shop", "board"], ["board", "shop"]); // true
3914
+ * ```
3915
+ */
3916
+ function sameNames$1(first, second) {
3917
+ return first.length === second.length && first.every((name) => second.includes(name));
3918
+ }
3919
+ /**
3920
+ * Collects every slot node of the graph by slot name: the flow that holds it and what it
3921
+ * declares, in the order the graph declares them.
3922
+ *
3923
+ * @param flows - Every collected flow by id.
3924
+ * @returns Slot name to its slot nodes, at least one each.
3925
+ */
3926
+ function slotDeclarations(flows) {
3927
+ const declarations = /* @__PURE__ */ new Map();
3928
+ for (const flow of flows.values()) for (const entry of Object.values(flow.nodes)) {
3929
+ if (entry.kind !== "slot") continue;
3930
+ const declaration = {
3931
+ flow: flow.id,
3932
+ declared: declaredOutcomes(entry.outcomes)
3933
+ };
3934
+ const list = declarations.get(entry.name);
3935
+ if (list === void 0) declarations.set(entry.name, [declaration]);
3936
+ else list.push(declaration);
3937
+ }
3938
+ return declarations;
3939
+ }
3940
+ /**
3941
+ * Reports the contributions of a slot that declares outcomes and that may leave it on an outcome
3942
+ * the slot does not declare: the loop would have no edge for it.
3943
+ *
3944
+ * @param slotName - Name of the slot.
3945
+ * @param declared - The outcomes the slot declares besides `done`.
3946
+ * @param contributions - The contributions of the slot.
3947
+ * @returns One sentence per contribution and outcome.
3948
+ */
3949
+ function undeclaredExits(slotName, declared, contributions) {
3950
+ const problems = [];
3951
+ for (const { feature, flow } of contributions) for (const outcome of declaredOutcomes(flow.outcomes)) {
3952
+ if (declared.includes(outcome)) continue;
3953
+ problems.push(`[game] Slot "${slotName}": flow "${flow.id}" of feature "${feature}" exits "${outcome}", which the slot does not declare.\n Declare slot("${slotName}", { ${outcome}: type() }) or exit "done".`);
3954
+ }
3955
+ return problems;
3956
+ }
3957
+ /**
3958
+ * Reports the named outcomes of slots: one slot name declared with different outcomes in two
3959
+ * places, and a contribution of a slot that declares outcomes which may leave it on another one.
3960
+ * A slot that declares none takes any contribution: what it ignores is a warning.
3961
+ *
3962
+ * @param flows - Every collected flow by id.
3963
+ * @param features - Features API, read for the contributions of every slot.
3964
+ * @returns One sentence per problem.
3965
+ */
3966
+ function slotOutcomeProblems(flows, features) {
3967
+ const problems = [];
3968
+ for (const [slotName, [first, ...others]] of slotDeclarations(flows)) {
3969
+ for (const other of others) {
3970
+ if (sameNames$1(first.declared, other.declared)) continue;
3971
+ problems.push(`[game] Slot "${slotName}" declares different outcomes in flow "${first.flow}" and flow "${other.flow}".\n Declare the slot once and use it in both flows.`);
3972
+ }
3973
+ if (first.declared.length === 0) continue;
3974
+ problems.push(...undeclaredExits(slotName, first.declared, features.contributions(slotName)));
3975
+ }
3976
+ return problems;
3977
+ }
3978
+ /**
3979
+ * Warns about the named exits of contributions that a slot declaring no outcomes ignores: the
3980
+ * slot hands over to its next contribution as after `done`.
3981
+ *
3982
+ * @param slotName - Name of the slot.
3983
+ * @param contributions - The contributions of the slot.
3984
+ * @returns One warning per contribution and outcome.
3985
+ */
3986
+ function ignoredExits(slotName, contributions) {
3987
+ return contributions.flatMap(({ flow }) => declaredOutcomes(flow.outcomes).map((outcome) => `[game] Slot "${slotName}": flow "${flow.id}" exits "${outcome}", which the slot ignores.\n Declare slot("${slotName}", { ${outcome}: type() }) to route it, or exit "done".`));
3988
+ }
3989
+ /**
3990
+ * Warns about contributions without `when` in a slot that declares outcomes: a named exit leaves
3991
+ * the slot, so a later one runs only when the one before it exits `done`.
3992
+ *
3993
+ * @param slotName - Name of the slot.
3994
+ * @param contributions - The contributions of the slot, in order.
3995
+ * @returns One warning per contribution without `when` after the first one.
3996
+ */
3997
+ function unguardedContributions(slotName, contributions) {
3998
+ const warnings = [];
3999
+ let earlier;
4000
+ for (const contribution of contributions) {
4001
+ if (contribution.when !== void 0) continue;
4002
+ if (earlier !== void 0) warnings.push(`[game] Slot "${slotName}": the flows "${earlier.flow.id}" and "${contribution.flow.id}" have no when.\n A named exit leaves the slot, so "${contribution.flow.id}" runs only when "${earlier.flow.id}" exits done; give them when.`);
4003
+ earlier = contribution;
4004
+ }
4005
+ return warnings;
4006
+ }
4007
+ /**
4008
+ * Warns about the named outcomes of slots: a named exit a slot that declares none ignores, and
4009
+ * contributions without `when` in a slot that declares some.
4010
+ *
4011
+ * @param flows - Every collected flow by id.
4012
+ * @param features - Features API, read for the contributions of every slot.
4013
+ * @returns One warning per finding.
4014
+ */
4015
+ function slotOutcomeWarnings(flows, features) {
4016
+ const warnings = [];
4017
+ for (const [slotName, [first]] of slotDeclarations(flows)) {
4018
+ const contributions = features.contributions(slotName);
4019
+ warnings.push(...first.declared.length === 0 ? ignoredExits(slotName, contributions) : unguardedContributions(slotName, contributions));
4020
+ }
4021
+ return warnings;
4022
+ }
4023
+ /**
3892
4024
  * Reads the scene id of one entry of a feature's `scenes` list. The list holds whatever
3893
4025
  * `defineScene` returns; validation reads the `id` and ignores everything else.
3894
4026
  *
@@ -4035,12 +4167,14 @@ function sizeWarnings(flow) {
4035
4167
  * Validates the whole graph at `run()` and returns every problem as one `[game] …` sentence:
4036
4168
  * unreachable node, outcome without an edge, missing target, barrier edge that does not reach a
4037
4169
  * rest node of the same flow, cycle with no rest node, slot without a name, equal contribution
4038
- * `order`, one flow contributed twice to a slot, `inbox` type that is not an outcome, rest node
4039
- * with neither `run` nor outcomes, a `recover` on a node that is not a rest node, a scene id no
4040
- * feature registers, an `over` node that names a scene, `safeNode` that is not a checkpoint,
4041
- * colliding ids. A `recover` on a node that is not the safe node is a warning: it never runs
4042
- * there. Types are bypassed by JSON data and casts, so this runs even though the edge table is
4043
- * checked at compile time.
4170
+ * `order`, one flow contributed twice to a slot, one slot name declared with different outcomes,
4171
+ * a contribution of a slot that declares outcomes which exits another one, `inbox` type that is
4172
+ * not an outcome, rest node with neither `run` nor outcomes, a `recover` on a node that is not a
4173
+ * rest node, a scene id no feature registers, an `over` node that names a scene, `safeNode` that
4174
+ * is not a checkpoint, colliding ids. A `recover` on a node that is not the safe node is a
4175
+ * warning: it never runs there. So are a named exit a slot that declares no outcomes ignores, and
4176
+ * two contributions without `when` in a slot that declares some. Types are bypassed by JSON data
4177
+ * and casts, so this runs even though the edge table is checked at compile time.
4044
4178
  *
4045
4179
  * @param flows - Every collected flow by id, the main flow first.
4046
4180
  * @param features - Features API: slot contributions and feature names.
@@ -4054,8 +4188,8 @@ function validateGraph(flows, features, config) {
4054
4188
  problems.push(...edgeProblems(flow), ...nodeProblems(flow), ...recoverProblems(flow), ...reachProblems(flow), ...cycleProblems(flow));
4055
4189
  warnings.push(...sizeWarnings(flow));
4056
4190
  }
4057
- warnings.push(...slotNameWarnings(flows, features), ...recoverWarnings(flows, features, config));
4058
- problems.push(...flowIdProblems(flows), ...featureProblems(flows, features), ...sceneProblems(flows, features), ...safeNodeProblems(config, features));
4191
+ warnings.push(...slotNameWarnings(flows, features), ...slotOutcomeWarnings(flows, features), ...recoverWarnings(flows, features, config));
4192
+ problems.push(...flowIdProblems(flows), ...featureProblems(flows, features), ...slotOutcomeProblems(flows, features), ...sceneProblems(flows, features), ...safeNodeProblems(config, features));
4059
4193
  return {
4060
4194
  problems,
4061
4195
  warnings
@@ -4545,7 +4679,9 @@ function pickContribution(ctx, features, slotName, from) {
4545
4679
  }
4546
4680
  /**
4547
4681
  * Follows one result to the next position: a node of the same flow, a mapped node, or the exit of
4548
- * a sub-flow, which hands the outcome to the parent.
4682
+ * a sub-flow, which hands the outcome to the parent. The exit of a slot contribution returns to
4683
+ * the slot: on `done` or an outcome the slot does not declare to continue it, on a declared
4684
+ * outcome to leave it.
4549
4685
  *
4550
4686
  * @param ctx - Domain context of the flow plugin.
4551
4687
  * @param frames - The position being planned; mutated.
@@ -4560,19 +4696,78 @@ function planFrom(ctx, frames, location, outcome, payload, depth) {
4560
4696
  const target = location.flow.edges[location.name]?.[outcome];
4561
4697
  if (target === void 0) return { problem: `[game] Flow "${location.flow.id}": outcome "${outcome}" of node "${location.name}" has no edge.\n Add edges.${location.name}.${outcome}.` };
4562
4698
  if (typeof target === "string") return enterTarget(ctx, frames, location.flow, target, payload);
4563
- if (target.kind === "map") {
4564
- const mapped = target.map(payload);
4565
- const input = mapped === void 0 ? null : mapped;
4566
- if (!isJson$1(input)) return { problem: `[game] The mapper of edge "${location.name}.${outcome}" returned a value that is not JSON.\n Return plain JSON from to("${target.target}", ...).` };
4567
- return enterTarget(ctx, frames, location.flow, target.target, input);
4568
- }
4699
+ if (target.kind === "map") return enterMapped(ctx, frames, location, outcome, target, payload);
4569
4700
  frames.pop();
4570
4701
  const parent = locateFrames(ctx, frames);
4571
4702
  if (parent === void 0) return { problem: `[game] The main flow "${location.flow.id}" left with outcome "${target.outcome}".\n Give the top-level flow an edge instead of exit("${target.outcome}").` };
4572
- if (parent.entry.kind === "slot") return continueSlot(frames, location.flow.id);
4703
+ if (parent.entry.kind === "slot") return leavesSlot(parent.entry, target.outcome) ? leaveSlot(frames, target.outcome, payload) : continueSlot(frames, location.flow.id);
4573
4704
  return planFrom(ctx, frames, parent, target.outcome, payload, depth + 1);
4574
4705
  }
4575
4706
  /**
4707
+ * Follows a `to()` edge: maps the payload and enters the target with it. The mapper is game code:
4708
+ * a throw, or a result that is not JSON, is a problem, and the node fails like any other.
4709
+ *
4710
+ * @param ctx - Domain context of the flow plugin.
4711
+ * @param frames - The position being planned; mutated.
4712
+ * @param location - Where the result was produced.
4713
+ * @param outcome - Outcome name of the result.
4714
+ * @param target - The mapped target of the edge.
4715
+ * @param payload - Payload of the result, handed to the mapper.
4716
+ * @returns The plan, or the problem that stops it.
4717
+ */
4718
+ function enterMapped(ctx, frames, location, outcome, target, payload) {
4719
+ let mapped;
4720
+ try {
4721
+ mapped = target.map(payload);
4722
+ } catch (error) {
4723
+ const message = error instanceof Error ? error.message : String(error);
4724
+ return { problem: `[game] The mapper of edge "${location.name}.${outcome}" threw: ${message}.\n Return plain JSON from to("${target.target}", ...).` };
4725
+ }
4726
+ const input = mapped === void 0 ? null : mapped;
4727
+ if (!isJson$1(input)) return { problem: `[game] The mapper of edge "${location.name}.${outcome}" returned a value that is not JSON.\n Return plain JSON from to("${target.target}", ...).` };
4728
+ return enterTarget(ctx, frames, location.flow, target.target, input);
4729
+ }
4730
+ /**
4731
+ * Tells whether a contribution that exits `outcome` leaves its slot at once: the slot declares the
4732
+ * outcome, and it is not `done`, which hands over to the next contribution. Own keys only:
4733
+ * `"constructor" in outcomes` is true for every object.
4734
+ *
4735
+ * @param slot - The slot the contribution runs in.
4736
+ * @param outcome - The outcome the contribution exited with.
4737
+ * @returns True when the slot takes its own edge of that outcome next.
4738
+ * @example
4739
+ * ```ts
4740
+ * leavesSlot(slot("episodeStep", { shop: type() }), "shop"); // true
4741
+ * leavesSlot(slot("episodeStep", { shop: type() }), "done"); // false: the next contribution runs
4742
+ * ```
4743
+ */
4744
+ function leavesSlot(slot, outcome) {
4745
+ return outcome !== "done" && Object.hasOwn(slot.outcomes, outcome);
4746
+ }
4747
+ /**
4748
+ * Plans the early leave of a slot: a contribution exited an outcome the slot declares. The plan
4749
+ * stands on the slot node like `continueSlot`, without `after`, and carries the outcome and its
4750
+ * payload: the loop takes the slot's own edge with them after the commit, so the later
4751
+ * contributions do not run and the next entry of the slot starts at its first one.
4752
+ *
4753
+ * @param frames - The position, already popped back to the slot node.
4754
+ * @param outcome - The declared outcome the contribution left on.
4755
+ * @param payload - Its payload, handed on to the slot's edge.
4756
+ * @returns The plan that stands on the slot node.
4757
+ */
4758
+ function leaveSlot(frames, outcome, payload) {
4759
+ return {
4760
+ stack: frames,
4761
+ next: framePath(frames),
4762
+ rest: false,
4763
+ checkpoint: false,
4764
+ leave: {
4765
+ outcome,
4766
+ payload
4767
+ }
4768
+ };
4769
+ }
4770
+ /**
4576
4771
  * Plans the return to a slot after one of its contributions ended. The next contribution is NOT
4577
4772
  * picked here: `when` reads the committed state, and a plan is made before the commit of the edge.
4578
4773
  * So the plan stands on the slot node and names the contribution that ended; the loop picks the
@@ -4609,7 +4804,7 @@ function asError$4(value) {
4609
4804
  /**
4610
4805
  * Commits the transaction of one node, releases its hints, journals the edge and emits
4611
4806
  * `flow:edge`. This is the only commit of the engine: the edge of a node, and the recovery edge
4612
- * of the safe node, both go through it.
4807
+ * of the safe node, both go through it. It counts the edge among those since the last rest node.
4613
4808
  *
4614
4809
  * @param ctx - Domain context of the flow plugin.
4615
4810
  * @param modules - Injected sibling APIs.
@@ -4619,6 +4814,7 @@ function asError$4(value) {
4619
4814
  */
4620
4815
  function commitEdge(ctx, modules, step, next) {
4621
4816
  const commit = step.transaction.commit();
4817
+ ctx.state.runner.restless += 1;
4622
4818
  ctx.deps.time.wake();
4623
4819
  modules.fx.release();
4624
4820
  const entry = pushEntry(ctx.state.runner, {
@@ -4682,6 +4878,8 @@ function handleFailure(ctx, modules, transaction, error, path, safe) {
4682
4878
  });
4683
4879
  state.stack = [...target];
4684
4880
  state.slotAfter = void 0;
4881
+ state.slotLeave = void 0;
4882
+ state.restless = 0;
4685
4883
  if (!retry) {
4686
4884
  safe.inside = true;
4687
4885
  safe.recover = true;
@@ -4707,9 +4905,11 @@ function handleAbort(modules, transaction, reason) {
4707
4905
  }
4708
4906
  /**
4709
4907
  * Moves the position to a planned place and, when that place is a rest node, makes it the rest
4710
- * point of the loop: the frame rollback and `bookmark` return to, a zeroed failure count, a
4711
- * compacted journal at a checkpoint, `flow:rest` and the walk's rest listeners. The caller marks the
4712
- * rest point in the store first. Used by the edge, and by the loop when it enters a slot.
4908
+ * point of the loop: the frame rollback and `bookmark` return to, a zeroed failure count, a zeroed
4909
+ * count of edges without a rest, a compacted journal at a checkpoint, `flow:rest` and the walk's
4910
+ * rest listeners. The caller marks the rest point in the store first. Used by the edge, and by the
4911
+ * loop when it enters a slot. A slot it stands on keeps the contribution that ended, or the
4912
+ * declared outcome one left on, for the next turn.
4713
4913
  *
4714
4914
  * @param ctx - Domain context of the flow plugin.
4715
4915
  * @param plan - Where the loop stands now.
@@ -4718,9 +4918,11 @@ function arrive(ctx, plan) {
4718
4918
  const state = ctx.state.runner;
4719
4919
  state.stack = plan.stack;
4720
4920
  state.slotAfter = plan.after;
4921
+ state.slotLeave = plan.leave;
4721
4922
  if (!plan.rest) return;
4722
4923
  state.restFrame = [...plan.stack];
4723
4924
  state.failures = 0;
4925
+ state.restless = 0;
4724
4926
  if (plan.checkpoint) compact(state);
4725
4927
  ctx.emit("flow:rest", {
4726
4928
  path: plan.next,
@@ -4730,7 +4932,9 @@ function arrive(ctx, plan) {
4730
4932
  }
4731
4933
  /**
4732
4934
  * Takes the edge of one result: plan, commit, mark the barrier or the rest point, move the
4733
- * position and announce a rest node.
4935
+ * position and announce a rest node. A plan with a problem fails the node, and so does an edge
4936
+ * beyond the cap of edges without a rest node: a loop of transit nodes that never rests would
4937
+ * freeze the game.
4734
4938
  *
4735
4939
  * @param ctx - Domain context of the flow plugin.
4736
4940
  * @param modules - Injected sibling APIs.
@@ -4742,6 +4946,10 @@ async function finishNode(ctx, modules, step, safe) {
4742
4946
  const state = ctx.state.runner;
4743
4947
  const plan = planFrom(ctx, [...state.stack], step.location, step.result.outcome, step.result.payload, 0);
4744
4948
  if ("problem" in plan) return handleFailure(ctx, modules, step.transaction, new Error(plan.problem), step.path, safe);
4949
+ if (state.restless >= state.restlessCap) {
4950
+ const restless = `[game] ${state.restlessCap} edges without a rest node, at "${step.path}".\n Put a rest node in the loop.`;
4951
+ return handleFailure(ctx, modules, step.transaction, new Error(restless), step.path, safe);
4952
+ }
4745
4953
  const entry = commitEdge(ctx, modules, step, plan.next);
4746
4954
  if (step.barrier) await ctx.deps.model.store.markBarrier(`${step.path}#${entry.index}@${step.now}`);
4747
4955
  else if (plan.rest) ctx.deps.model.store.markRest();
@@ -4891,27 +5099,47 @@ function to(target, map) {
4891
5099
  };
4892
5100
  }
4893
5101
  /**
4894
- * Defines an extension point: a node whose body is "run the contributions of this slot in order".
4895
- * Its single outcome is `done`.
5102
+ * Builds the slot. The body works on the widened shapes the runner reads: the signature above has
5103
+ * already kept `done` out of the declared outcomes for a typed caller, and the check here refuses
5104
+ * it past a cast.
4896
5105
  *
4897
5106
  * @param name - Slot name features contribute to.
5107
+ * @param outcomes - The named outcomes besides `done`.
4898
5108
  * @returns The slot node as plain data.
4899
- * @example
4900
- * ```ts
4901
- * // The main flow leaves room after a finished order. Features contribute sub-flows to it.
4902
- * nodes: { boot, home, board: boardFlow, afterOrder: slot("afterOrder") }
4903
- * // slot("afterOrder"): { kind: "slot", name: "afterOrder", input: …, outcomes: { done: … } }
4904
- * ```
5109
+ * @throws {TypeError} When `outcomes` declares `done`.
4905
5110
  */
4906
- function slot(name) {
5111
+ function slot(name, outcomes) {
5112
+ if (outcomes !== void 0 && Object.hasOwn(outcomes, "done")) throw new TypeError(`[game] Slot "${name}" declares the outcome "done".\n done is every slot's own outcome; remove it from slot("${name}", ...).`);
4907
5113
  return {
4908
5114
  kind: "slot",
4909
5115
  name,
4910
5116
  input: type(),
4911
- outcomes: { done: type() }
5117
+ outcomes: {
5118
+ done: type(),
5119
+ ...outcomes
5120
+ }
4912
5121
  };
4913
5122
  }
4914
5123
  /**
5124
+ * Builds the entry. The body works on the widened shapes the runner reads: the signature above
5125
+ * has already checked the flow against the slot.
5126
+ *
5127
+ * @param host - The slot node the flow runs in.
5128
+ * @param spec - The flow, its `order` and an optional `when`.
5129
+ * @returns The entry under the slot's name.
5130
+ */
5131
+ function contribution(host, spec) {
5132
+ const { flow, order, when } = spec;
5133
+ return { [host.name]: when === void 0 ? {
5134
+ flow,
5135
+ order
5136
+ } : {
5137
+ flow,
5138
+ order,
5139
+ when
5140
+ } };
5141
+ }
5142
+ /**
4915
5143
  * Builds the `out` of a node context: one method per declared outcome. A method called without
4916
5144
  * data produces a `null` payload. Called by the loop when it enters a node, never by a game.
4917
5145
  *
@@ -5676,7 +5904,8 @@ function enterSubFlow(ctx, modules, location, sub, safe) {
5676
5904
  }
5677
5905
  /**
5678
5906
  * Enters a slot: the first contribution whose `when` passes, or the slot's `done` edge when the
5679
- * slot has no contribution to run.
5907
+ * slot has no contribution to run. When a contribution just left on an outcome the slot declares,
5908
+ * the slot takes its own edge of that outcome instead, with the contribution's payload.
5680
5909
  *
5681
5910
  * @param ctx - Domain context of the flow plugin.
5682
5911
  * @param modules - Injected sibling APIs.
@@ -5687,6 +5916,14 @@ function enterSubFlow(ctx, modules, location, sub, safe) {
5687
5916
  */
5688
5917
  function enterSlot(ctx, modules, location, slot, safe) {
5689
5918
  const state = ctx.state.runner;
5919
+ const leave = state.slotLeave;
5920
+ if (leave !== void 0) {
5921
+ state.slotLeave = void 0;
5922
+ return applyResult(ctx, modules, location, leave, safe, {
5923
+ barrier: false,
5924
+ now: ctx.deps.clock.now()
5925
+ });
5926
+ }
5690
5927
  const finished = state.slotAfter;
5691
5928
  const contributions = modules.features.contributions(slot.name);
5692
5929
  const from = finished === void 0 ? 0 : contributions.findIndex((item) => item.flow.id === finished) + 1;
@@ -5742,8 +5979,10 @@ function enterBookmark(ctx, modules, bookmark, safe) {
5742
5979
  ctx.deps.model.store.markRest();
5743
5980
  state.stack = framesOf(location.trail, bookmark.input);
5744
5981
  state.slotAfter = void 0;
5982
+ state.slotLeave = void 0;
5745
5983
  state.restFrame = [...state.stack];
5746
5984
  state.failures = 0;
5985
+ state.restless = 0;
5747
5986
  ctx.emit("flow:rest", {
5748
5987
  path: bookmark.path,
5749
5988
  checkpoint: isRestCheckpoint(location.entry)
@@ -5759,7 +5998,7 @@ function enterBookmark(ctx, modules, bookmark, safe) {
5759
5998
  * @returns `"stop"` when the runner was stopped, `"continue"` otherwise.
5760
5999
  * @throws {Error} When the position names no node of the graph.
5761
6000
  */
5762
- async function advance(ctx, modules, safe) {
6001
+ async function advance$1(ctx, modules, safe) {
5763
6002
  const state = ctx.state.runner;
5764
6003
  if (stopped(state)) return "stop";
5765
6004
  const restoring = state.seam?.restoring;
@@ -5777,7 +6016,8 @@ async function advance(ctx, modules, safe) {
5777
6016
  * Runs the graph until aborted: enter node, `onEnter` stages, begin a transaction, await the
5778
6017
  * body, commit on the edge, release hints, journal, emit `flow:edge`, follow the edge. An error
5779
6018
  * discards, rolls back, is logged and retries, then enters `safeNode`, whose `recover` runs first
5780
- * as one committed edge; a failure inside `safeNode` rejects.
6019
+ * as one committed edge; a failure inside `safeNode` rejects. More edges without a rest node than
6020
+ * the graph's cap is such an error too.
5781
6021
  * Abort reasons: `"stop"` ends the loop, `"inbox"` turns the event into the outcome, `"restore"`
5782
6022
  * enters the bookmark's frame.
5783
6023
  *
@@ -5791,6 +6031,7 @@ async function runLoop(ctx, modules) {
5791
6031
  const state = ctx.state.runner;
5792
6032
  state.flows = collectGraph(ctx, modules.features);
5793
6033
  checkGraph(ctx, modules.features);
6034
+ state.restlessCap = restlessCapOf(main, modules.features.contributions);
5794
6035
  modules.features.seal();
5795
6036
  await ctx.deps.model.store.load();
5796
6037
  state.stack = [{
@@ -5800,13 +6041,15 @@ async function runLoop(ctx, modules) {
5800
6041
  }];
5801
6042
  state.restFrame = [...state.stack];
5802
6043
  state.slotAfter = void 0;
6044
+ state.slotLeave = void 0;
5803
6045
  state.failures = 0;
6046
+ state.restless = 0;
5804
6047
  const safe = {
5805
6048
  inside: false,
5806
6049
  recover: false
5807
6050
  };
5808
6051
  const loop = { running: true };
5809
- while (loop.running) loop.running = await advance(ctx, modules, safe) === "continue";
6052
+ while (loop.running) loop.running = await advance$1(ctx, modules, safe) === "continue";
5810
6053
  }
5811
6054
  /**
5812
6055
  * Restores a bookmark through the running loop: the active node is aborted with reason
@@ -6680,6 +6923,9 @@ function createInboxState() {
6680
6923
  //#endregion
6681
6924
  //#region src/plugins/flow/runner/state.ts
6682
6925
  /**
6926
+ * @file flow/runner — state factory.
6927
+ */
6928
+ /**
6683
6929
  * Creates the empty flow registry. It lives in its own function because the plugin's lint rule
6684
6930
  * L5 refuses a collection built inside an exported declaration.
6685
6931
  *
@@ -6709,6 +6955,9 @@ function createRunnerState() {
6709
6955
  stack: [],
6710
6956
  restFrame: void 0,
6711
6957
  slotAfter: void 0,
6958
+ slotLeave: void 0,
6959
+ restless: 0,
6960
+ restlessCap: 64,
6712
6961
  journal: [],
6713
6962
  journalIndex: 0,
6714
6963
  running: void 0,
@@ -24939,7 +25188,7 @@ function finishRun(warm) {
24939
25188
  * @param pixi - The module that draws.
24940
25189
  * @returns The tree, or `undefined` when it cannot be built.
24941
25190
  */
24942
- function objectsOf(sctx, pixi) {
25191
+ function objectsOf$1(sctx, pixi) {
24943
25192
  const { warm } = sctx.ctx.state.sync;
24944
25193
  if (warm.objects !== void 0) return warm.objects;
24945
25194
  try {
@@ -24967,7 +25216,7 @@ function warmStep(sctx) {
24967
25216
  const { warm } = sctx.ctx.state.sync;
24968
25217
  if (warm.head >= warm.queue.length) return false;
24969
25218
  const pixi = sctx.deps.host.pixi();
24970
- const objects = pixi === void 0 ? void 0 : objectsOf(sctx, pixi);
25219
+ const objects = pixi === void 0 ? void 0 : objectsOf$1(sctx, pixi);
24971
25220
  if (pixi === void 0 || objects === void 0) return false;
24972
25221
  const budget = sctx.ctx.config.warm;
24973
25222
  let drawn = 0;
@@ -26026,6 +26275,1003 @@ function clearLayers(state) {
26026
26275
  state.hudLayer = void 0;
26027
26276
  }
26028
26277
  //#endregion
26278
+ //#region src/plugins/renderer/sync/shape-path.ts
26279
+ /**
26280
+ * @file renderer/sync — the path of a `Shape`: a rectangle, a rounded rectangle or a triangle,
26281
+ * its fill, and its stroke, solid or dashed, or an arc, a stroke alone. A dashed stroke walks the
26282
+ * outline (straight edges, and rounded corners sampled as arcs) and strokes one open line per dash.
26283
+ * An arc runs on the circle inscribed in its box, from 12 o'clock clockwise.
26284
+ */
26285
+ /** Chords per rounded corner of a dashed outline: under 0.3 u off the arc at a 48 u radius. */
26286
+ const CORNER_SAMPLES = 8;
26287
+ /** The gap between two dashes, as a fraction of the dash length. */
26288
+ const GAP_RATIO = .5;
26289
+ /** Segments of a full ring: a ring of 28 u across strays under 0.03 u off its circle. */
26290
+ const RING_SEGMENTS = 48;
26291
+ /** The fewest segments of an arc that draws: a sliver still bends. */
26292
+ const ARC_MIN_SEGMENTS = 2;
26293
+ /** 12 o'clock, where an arc starts: Pixi's angles run clockwise from 3 o'clock. */
26294
+ const TWELVE = -Math.PI / 2;
26295
+ /**
26296
+ * The corner radius the rectangle is drawn with: Pixi clamps it to half the shorter side.
26297
+ *
26298
+ * @param value - The shape value.
26299
+ * @returns The radius in reference units, 0 for square corners.
26300
+ * @example
26301
+ * ```ts
26302
+ * radiusOf({ ...Shape.defaults, w: 100, h: 20, radius: 40 }); // 10
26303
+ * ```
26304
+ */
26305
+ function radiusOf(value) {
26306
+ return Math.max(0, Math.min(value.radius, value.w / 2, value.h / 2));
26307
+ }
26308
+ /**
26309
+ * The closed outline of a shape as points, clockwise from the top left (from the end of the top
26310
+ * left corner on a rounded rectangle); the last point repeats the first.
26311
+ *
26312
+ * @param value - The shape value.
26313
+ * @returns The points of the outline.
26314
+ * @example
26315
+ * ```ts
26316
+ * outlineOf({ ...Shape.defaults, kind: "triangle", w: 60, h: 40 });
26317
+ * // [{ x: 0, y: 0 }, { x: 60, y: 20 }, { x: 0, y: 40 }, { x: 0, y: 0 }]
26318
+ * ```
26319
+ */
26320
+ function outlineOf(value) {
26321
+ const { w, h } = value;
26322
+ if (value.kind === "triangle") return [
26323
+ {
26324
+ x: 0,
26325
+ y: 0
26326
+ },
26327
+ {
26328
+ x: w,
26329
+ y: h / 2
26330
+ },
26331
+ {
26332
+ x: 0,
26333
+ y: h
26334
+ },
26335
+ {
26336
+ x: 0,
26337
+ y: 0
26338
+ }
26339
+ ];
26340
+ const radius = radiusOf(value);
26341
+ if (radius === 0) return [
26342
+ {
26343
+ x: 0,
26344
+ y: 0
26345
+ },
26346
+ {
26347
+ x: w,
26348
+ y: 0
26349
+ },
26350
+ {
26351
+ x: w,
26352
+ y: h
26353
+ },
26354
+ {
26355
+ x: 0,
26356
+ y: h
26357
+ },
26358
+ {
26359
+ x: 0,
26360
+ y: 0
26361
+ }
26362
+ ];
26363
+ const corners = [
26364
+ {
26365
+ edge: {
26366
+ x: w - radius,
26367
+ y: 0
26368
+ },
26369
+ center: {
26370
+ x: w - radius,
26371
+ y: radius
26372
+ }
26373
+ },
26374
+ {
26375
+ edge: {
26376
+ x: w,
26377
+ y: h - radius
26378
+ },
26379
+ center: {
26380
+ x: w - radius,
26381
+ y: h - radius
26382
+ }
26383
+ },
26384
+ {
26385
+ edge: {
26386
+ x: radius,
26387
+ y: h
26388
+ },
26389
+ center: {
26390
+ x: radius,
26391
+ y: h - radius
26392
+ }
26393
+ },
26394
+ {
26395
+ edge: {
26396
+ x: 0,
26397
+ y: radius
26398
+ },
26399
+ center: {
26400
+ x: radius,
26401
+ y: radius
26402
+ }
26403
+ }
26404
+ ];
26405
+ const points = [{
26406
+ x: radius,
26407
+ y: 0
26408
+ }];
26409
+ for (const [index, { edge, center }] of corners.entries()) {
26410
+ const from = -Math.PI / 2 + index * Math.PI / 2;
26411
+ points.push(edge);
26412
+ for (let sample = 1; sample <= CORNER_SAMPLES; sample += 1) {
26413
+ const angle = from + sample * Math.PI / 2 / CORNER_SAMPLES;
26414
+ points.push({
26415
+ x: center.x + radius * Math.cos(angle),
26416
+ y: center.y + radius * Math.sin(angle)
26417
+ });
26418
+ }
26419
+ }
26420
+ return points;
26421
+ }
26422
+ /**
26423
+ * Adds a point to a line unless the line already ends there.
26424
+ *
26425
+ * @param line - The line.
26426
+ * @param point - The point.
26427
+ */
26428
+ function extend(line, point) {
26429
+ const last = line.at(-1);
26430
+ if (last?.x !== point.x || last.y !== point.y) line.push(point);
26431
+ }
26432
+ /**
26433
+ * Cuts a polyline into dashes of `dash` length with gaps of half a dash, starting with a dash at
26434
+ * its first point. A dash bends round the corners it crosses; the last one may be shorter.
26435
+ *
26436
+ * @param outline - The points of the polyline.
26437
+ * @param dash - The dash length, above 0.
26438
+ * @returns One open line per dash.
26439
+ * @example
26440
+ * ```ts
26441
+ * dashesOf([{ x: 0, y: 0 }, { x: 40, y: 0 }], 10);
26442
+ * // [[{ x: 0, y: 0 }, { x: 10, y: 0 }], [{ x: 15, y: 0 }, { x: 25, y: 0 }], [{ x: 30, y: 0 }, { x: 40, y: 0 }]]
26443
+ * ```
26444
+ */
26445
+ function dashesOf(outline, dash) {
26446
+ const first = outline[0];
26447
+ if (first === void 0) return [];
26448
+ const dashes = [];
26449
+ let line = [first];
26450
+ let left = dash;
26451
+ for (let index = 1; index < outline.length; index += 1) {
26452
+ let from = outline[index - 1] ?? first;
26453
+ const to = outline[index] ?? first;
26454
+ let length = Math.hypot(to.x - from.x, to.y - from.y);
26455
+ while (length > left) {
26456
+ from = {
26457
+ x: from.x + (to.x - from.x) * left / length,
26458
+ y: from.y + (to.y - from.y) * left / length
26459
+ };
26460
+ length -= left;
26461
+ if (line === void 0) {
26462
+ line = [from];
26463
+ left = dash;
26464
+ } else {
26465
+ extend(line, from);
26466
+ dashes.push(line);
26467
+ line = void 0;
26468
+ left = dash * GAP_RATIO;
26469
+ }
26470
+ }
26471
+ left -= length;
26472
+ if (line !== void 0) extend(line, to);
26473
+ }
26474
+ if (line !== void 0 && line.length > 1) dashes.push(line);
26475
+ return dashes;
26476
+ }
26477
+ /**
26478
+ * Draws the path of a shape and fills it in a colour at the alpha given. At alpha 0 the path stays unfilled, so only
26479
+ * a stroke shows. A clip mask is drawn here too, never dashed; the path of an arc here is its box, which is what it
26480
+ * clips its children to. A fill-only shape is drawn white and coloured by the object's tint, so a colour change draws
26481
+ * no path (spec 26 R2.1).
26482
+ *
26483
+ * @param object - The graphics to draw on.
26484
+ * @param value - The shape value.
26485
+ * @param fillAlpha - The alpha of the fill: the value's own for the shape, 1 for a clip mask.
26486
+ * @param color - The colour of the fill: the value's own by default, white for a tinted shape.
26487
+ */
26488
+ function drawShapePath(object, value, fillAlpha, color = value.fill) {
26489
+ object.clear();
26490
+ if (value.kind === "triangle") object.moveTo(0, 0).lineTo(value.w, value.h / 2).lineTo(0, value.h).closePath();
26491
+ else if (value.kind === "rect" && value.radius > 0) object.roundRect(0, 0, value.w, value.h, value.radius);
26492
+ else object.rect(0, 0, value.w, value.h);
26493
+ if (fillAlpha <= 0) return;
26494
+ object.fill(fillAlpha < 1 ? {
26495
+ color,
26496
+ alpha: fillAlpha
26497
+ } : { color });
26498
+ }
26499
+ /**
26500
+ * Strokes the outline drawn by `drawShapePath`: solid over the same path, or, with a `dash`, as
26501
+ * one open line per dash on a new path. Nothing without a stroke width.
26502
+ *
26503
+ * @param object - The graphics the shape was drawn on.
26504
+ * @param value - The shape value.
26505
+ */
26506
+ function strokeShape(object, value) {
26507
+ if (value.strokeWidth <= 0) return;
26508
+ const style = {
26509
+ color: value.stroke,
26510
+ width: value.strokeWidth
26511
+ };
26512
+ if (value.dash <= 0) {
26513
+ object.stroke(style);
26514
+ return;
26515
+ }
26516
+ object.beginPath();
26517
+ for (const [start, ...rest] of dashesOf(outlineOf(value), value.dash)) {
26518
+ if (start === void 0) continue;
26519
+ object.moveTo(start.x, start.y);
26520
+ for (const point of rest) object.lineTo(point.x, point.y);
26521
+ }
26522
+ object.stroke(style);
26523
+ }
26524
+ /**
26525
+ * The share of the circle an arc sweeps, clamped to 0..1: anything not above 0, `NaN` too, is 0; a whole ring when
26526
+ * the value leaves it out.
26527
+ *
26528
+ * @param value - The shape value.
26529
+ * @returns The share.
26530
+ * @example
26531
+ * ```ts
26532
+ * sweepOf({ ...Shape.defaults, kind: "arc", sweep: 1.4 }); // 1
26533
+ * ```
26534
+ */
26535
+ function sweepOf(value) {
26536
+ const sweep = value.sweep ?? arcDefaults.sweep;
26537
+ if (Number.isNaN(sweep) || sweep <= 0) return 0;
26538
+ return Math.min(sweep, 1);
26539
+ }
26540
+ /**
26541
+ * The radius of the circle an arc runs on: inscribed in the shorter side of its box, inside its stroke.
26542
+ *
26543
+ * @param value - The shape value.
26544
+ * @returns The radius in reference units; 0 or below when the stroke leaves no circle.
26545
+ * @example
26546
+ * ```ts
26547
+ * arcRadiusOf({ ...Shape.defaults, kind: "arc", w: 28, h: 40, strokeWidth: 2 }); // 13
26548
+ * ```
26549
+ */
26550
+ function arcRadiusOf(value) {
26551
+ return (Math.min(value.w, value.h) - value.strokeWidth) / 2;
26552
+ }
26553
+ /**
26554
+ * Tells whether an arc draws nothing: no stroke width, no sweep, or no circle inside its stroke.
26555
+ *
26556
+ * @param value - The shape value, an arc.
26557
+ * @returns True for an arc whose `Graphics` would hold no instruction.
26558
+ * @example
26559
+ * ```ts
26560
+ * isEmptyArc({ ...Shape.defaults, kind: "arc", w: 28, h: 28, strokeWidth: 2, sweep: 0 }); // true
26561
+ * ```
26562
+ */
26563
+ function isEmptyArc(value) {
26564
+ return value.strokeWidth <= 0 || sweepOf(value) <= 0 || arcRadiusOf(value) <= 0;
26565
+ }
26566
+ /**
26567
+ * Strokes an arc: from 12 o'clock clockwise over its share of the circle, in segments as many as its angle needs (48
26568
+ * for a ring, at least 2), ended by its `cap`. A full ring is closed and has no ends. Nothing for an empty arc. Drawn
26569
+ * only when the shape changed: never rebuilt per frame.
26570
+ *
26571
+ * @param object - The graphics, cleared.
26572
+ * @param value - The shape value, an arc.
26573
+ */
26574
+ function strokeArc(object, value) {
26575
+ if (isEmptyArc(value)) return;
26576
+ const sweep = sweepOf(value);
26577
+ const radius = arcRadiusOf(value);
26578
+ const ring = sweep >= 1;
26579
+ const segments = Math.max(ARC_MIN_SEGMENTS, Math.ceil(sweep * RING_SEGMENTS));
26580
+ const step = sweep * 2 * Math.PI / segments;
26581
+ const centreX = value.w / 2;
26582
+ const centreY = value.h / 2;
26583
+ const last = ring ? segments - 1 : segments;
26584
+ object.moveTo(centreX, centreY - radius);
26585
+ for (let index = 1; index <= last; index += 1) {
26586
+ const angle = TWELVE + index * step;
26587
+ object.lineTo(centreX + radius * Math.cos(angle), centreY + radius * Math.sin(angle));
26588
+ }
26589
+ if (ring) {
26590
+ object.closePath();
26591
+ object.stroke({
26592
+ color: value.stroke,
26593
+ width: value.strokeWidth
26594
+ });
26595
+ return;
26596
+ }
26597
+ object.stroke({
26598
+ color: value.stroke,
26599
+ width: value.strokeWidth,
26600
+ cap: value.cap ?? arcDefaults.cap
26601
+ });
26602
+ }
26603
+ /**
26604
+ * Paints a shape on its graphics: an arc as its stroke alone, every other kind as its path, its fill in a colour at its
26605
+ * own fill alpha, and its stroke. The one call that draws a shape's visual.
26606
+ *
26607
+ * @param object - The graphics.
26608
+ * @param value - The shape value.
26609
+ * @param color - The colour of the fill: white for a tinted shape, the value's own otherwise.
26610
+ */
26611
+ function paintShape(object, value, color) {
26612
+ if (value.kind === "arc") {
26613
+ object.clear();
26614
+ strokeArc(object, value);
26615
+ return;
26616
+ }
26617
+ drawShapePath(object, value, value.fillAlpha, color);
26618
+ strokeShape(object, value);
26619
+ }
26620
+ //#endregion
26621
+ //#region src/plugins/renderer/sync/textures.ts
26622
+ /** The colour a missing texture is drawn in. */
26623
+ const PLACEHOLDER_TINT = 16711935;
26624
+ /**
26625
+ * Creates an empty set. It lives in its own non-exported function because lint rule L5 refuses a
26626
+ * collection built inside an exported declaration.
26627
+ *
26628
+ * @returns An empty set.
26629
+ */
26630
+ function emptySet$5() {
26631
+ return /* @__PURE__ */ new Set();
26632
+ }
26633
+ /**
26634
+ * Asks the providers from the newest to the oldest. The first answer wins, so a bundle loaded
26635
+ * later shadows an older one.
26636
+ *
26637
+ * @param sctx - Domain context of the sync module.
26638
+ * @param key - The asset key.
26639
+ * @returns The texture, or `undefined` when nothing answered.
26640
+ */
26641
+ function resolveTexture(sctx, key) {
26642
+ const providers = sctx.ctx.state.sync.providers;
26643
+ for (let index = providers.length - 1; index >= 0; index -= 1) {
26644
+ const texture = providers[index]?.(key);
26645
+ if (texture !== void 0) return texture;
26646
+ }
26647
+ }
26648
+ /**
26649
+ * The 1x1 white texture the placeholder is drawn with.
26650
+ *
26651
+ * @param sctx - Domain context of the sync module.
26652
+ * @returns The texture, or `undefined` while inert.
26653
+ */
26654
+ function placeholderTexture(sctx) {
26655
+ return sctx.deps.host.pixi()?.Texture.WHITE;
26656
+ }
26657
+ /**
26658
+ * Reports a key no provider answers. One key warns once, however many views use it.
26659
+ *
26660
+ * @param sctx - Domain context of the sync module.
26661
+ * @param key - The asset key.
26662
+ */
26663
+ function warnMissing(sctx, key) {
26664
+ const warned = sctx.ctx.state.sync.warned;
26665
+ if (key === "" || warned.has(key)) return;
26666
+ warned.add(key);
26667
+ sctx.ctx.log.warn("renderer: no texture for asset key", { key });
26668
+ }
26669
+ /**
26670
+ * Remembers that an entity draws a key, so `invalidate` finds it again.
26671
+ *
26672
+ * @param state - The sync branch of the plugin state.
26673
+ * @param entity - The entity.
26674
+ * @param key - The asset key.
26675
+ */
26676
+ function trackKey(state, entity, key) {
26677
+ const users = state.byKey.get(key) ?? emptySet$5();
26678
+ users.add(entity);
26679
+ state.byKey.set(key, users);
26680
+ }
26681
+ /**
26682
+ * Forgets that an entity draws a key.
26683
+ *
26684
+ * @param state - The sync branch of the plugin state.
26685
+ * @param entity - The entity.
26686
+ * @param key - The asset key.
26687
+ */
26688
+ function untrackKey(state, entity, key) {
26689
+ const users = state.byKey.get(key);
26690
+ if (users === void 0) return;
26691
+ users.delete(entity);
26692
+ if (users.size === 0) state.byKey.delete(key);
26693
+ }
26694
+ /**
26695
+ * The Pixi module of a renderer that draws. A texture made while it does not would be lost with
26696
+ * the device, so the member that asked throws.
26697
+ *
26698
+ * @param sctx - Domain context of the sync module.
26699
+ * @param member - The `textures` member that asked, for the message.
26700
+ * @returns The Pixi module.
26701
+ * @throws {Error} When the renderer does not draw.
26702
+ */
26703
+ function drawingPixi(sctx, member) {
26704
+ const pixi = sctx.deps.host.pixi();
26705
+ if (pixi === void 0 || !sctx.deps.host.ready()) throw new Error(`[game] renderer.sync.textures.${member} needs a ready renderer.\n Check app.renderer.host.ready() first.`);
26706
+ return pixi;
26707
+ }
26708
+ /**
26709
+ * The default borders of a texture, from the four numbers `nine` gives.
26710
+ *
26711
+ * @param nine - Left, top, right and bottom in pixels.
26712
+ * @returns The borders, as Pixi reads them.
26713
+ * @example
26714
+ * ```ts
26715
+ * defaultBordersOf([24, 20, 16, 12]); // { left: 24, top: 20, right: 16, bottom: 12 }
26716
+ * ```
26717
+ */
26718
+ function defaultBordersOf(nine) {
26719
+ return {
26720
+ left: nine[0],
26721
+ top: nine[1],
26722
+ right: nine[2],
26723
+ bottom: nine[3]
26724
+ };
26725
+ }
26726
+ /**
26727
+ * Makes a Pixi texture out of a decoded image, on behalf of `assets`.
26728
+ *
26729
+ * @param sctx - Domain context of the sync module.
26730
+ * @param image - The decoded image.
26731
+ * @param options - Nine-slice borders in pixels, and whether Pixi generates the mip chain.
26732
+ * @returns The new texture.
26733
+ * @throws {Error} When the renderer does not draw.
26734
+ */
26735
+ function createTexture(sctx, image, options) {
26736
+ const pixi = drawingPixi(sctx, "create");
26737
+ const base = options?.mips === true ? pixi.Texture.from({
26738
+ resource: image,
26739
+ autoGenerateMipmaps: true
26740
+ }) : pixi.Texture.from(image);
26741
+ const nine = options?.nine;
26742
+ if (nine === void 0) return base;
26743
+ const bordered = new pixi.Texture({
26744
+ source: base.source,
26745
+ defaultBorders: defaultBordersOf(nine)
26746
+ });
26747
+ base.destroy(false);
26748
+ return bordered;
26749
+ }
26750
+ /**
26751
+ * Tells whether a frame lies inside a page of the given size. A negative number, or one that is
26752
+ * not a number, lies outside.
26753
+ *
26754
+ * @param frame - The frame in page pixels.
26755
+ * @param page - The size of the page.
26756
+ * @param page.width - Width of the page in pixels.
26757
+ * @param page.height - Height of the page in pixels.
26758
+ * @returns True when the whole frame is on the page.
26759
+ * @example
26760
+ * ```ts
26761
+ * frameFits({ x: 256, y: 384, width: 256, height: 128 }, { width: 512, height: 512 }); // true
26762
+ * ```
26763
+ */
26764
+ function frameFits(frame, page) {
26765
+ return frame.x >= 0 && frame.y >= 0 && frame.width >= 0 && frame.height >= 0 && frame.x + frame.width <= page.width && frame.y + frame.height <= page.height;
26766
+ }
26767
+ /**
26768
+ * Cuts a texture out of an atlas page, on behalf of `assets`. The slice shares the page's source,
26769
+ * its frame is offset by the page's own frame, and it is marked as a slice so `destroyTexture`
26770
+ * frees only the wrapper.
26771
+ *
26772
+ * @param sctx - Domain context of the sync module.
26773
+ * @param page - The page texture.
26774
+ * @param frame - The frame in page pixels.
26775
+ * @param options - Nine-slice borders, in pixels.
26776
+ * @returns The slice.
26777
+ * @throws {Error} When the renderer does not draw, and when the frame does not fit in the page.
26778
+ */
26779
+ function sliceTexture(sctx, page, frame, options) {
26780
+ const pixi = drawingPixi(sctx, "slice");
26781
+ if (!frameFits(frame, page)) throw new Error(`[game] renderer.sync.textures.slice: frame ${frame.x},${frame.y} ${frame.width}x${frame.height} is outside page ${page.width}x${page.height}.\n Run "bun run assets:pack".`);
26782
+ const nine = options?.nine;
26783
+ const slice = new pixi.Texture({
26784
+ source: page.source,
26785
+ frame: new pixi.Rectangle(page.frame.x + frame.x, page.frame.y + frame.y, frame.width, frame.height),
26786
+ ...nine === void 0 ? {} : { defaultBorders: defaultBordersOf(nine) }
26787
+ });
26788
+ sctx.ctx.state.sync.slices.add(slice);
26789
+ return slice;
26790
+ }
26791
+ /**
26792
+ * The cache key of one crop: the asset key and the box it covers.
26793
+ *
26794
+ * @param key - The asset key.
26795
+ * @param width - Width of the box.
26796
+ * @param height - Height of the box.
26797
+ * @returns The key of the crop.
26798
+ * @example
26799
+ * ```ts
26800
+ * frameKeyOf("board.bg", 1080, 1920); // "board.bg@1080x1920"
26801
+ * ```
26802
+ */
26803
+ function frameKeyOf(key, width, height) {
26804
+ return `${key}@${width}x${height}`;
26805
+ }
26806
+ /**
26807
+ * The texture a `"cover"` sprite draws: the part of `base` inside `crop`, sharing its source. One
26808
+ * crop per asset key and box, shared by every entity that shows it; the entity is added to its
26809
+ * users. A key that answers a new base has been invalidated, so every view of it resolves again
26810
+ * in this pass and joins the new crop; the old crop goes at once.
26811
+ *
26812
+ * @param sctx - Domain context of the sync module.
26813
+ * @param entity - The entity that shows the crop.
26814
+ * @param key - The asset key the base was resolved for.
26815
+ * @param base - The texture the key resolves to.
26816
+ * @param box - The box the sprite covers, which names the crop.
26817
+ * @param box.width - Width of the box.
26818
+ * @param box.height - Height of the box.
26819
+ * @param crop - The part of the base to show, in texture pixels.
26820
+ * @returns The crop and its `frames` key, or the base and `""` while inert.
26821
+ */
26822
+ function coverTexture(sctx, entity, key, base, box, crop) {
26823
+ const frames = sctx.ctx.state.sync.frames;
26824
+ const frameKey = frameKeyOf(key, box.width, box.height);
26825
+ const cached = frames.get(frameKey);
26826
+ if (cached?.base === base) {
26827
+ cached.users.add(entity);
26828
+ return {
26829
+ texture: cached.texture,
26830
+ frameKey
26831
+ };
26832
+ }
26833
+ const pixi = sctx.deps.host.pixi();
26834
+ if (pixi === void 0) return {
26835
+ texture: base,
26836
+ frameKey: ""
26837
+ };
26838
+ if (cached !== void 0) cached.texture.destroy(false);
26839
+ const texture = new pixi.Texture({
26840
+ source: base.source,
26841
+ frame: new pixi.Rectangle(base.frame.x + crop.x, base.frame.y + crop.y, crop.width, crop.height)
26842
+ });
26843
+ const users = emptySet$5();
26844
+ users.add(entity);
26845
+ frames.set(frameKey, {
26846
+ base,
26847
+ texture,
26848
+ users
26849
+ });
26850
+ return {
26851
+ texture,
26852
+ frameKey
26853
+ };
26854
+ }
26855
+ /**
26856
+ * Lets an entity go of the crop it showed. The last user frees the crop, keeping the source it
26857
+ * shares with its base.
26858
+ *
26859
+ * @param state - The sync branch of the plugin state.
26860
+ * @param entity - The entity that no longer shows it.
26861
+ * @param frameKey - The `frames` key of the crop; `""` for none.
26862
+ */
26863
+ function releaseCover(state, entity, frameKey) {
26864
+ const frame = state.frames.get(frameKey);
26865
+ if (frame === void 0) return;
26866
+ frame.users.delete(entity);
26867
+ if (frame.users.size > 0) return;
26868
+ frame.texture.destroy(false);
26869
+ state.frames.delete(frameKey);
26870
+ }
26871
+ /**
26872
+ * Frees the crops cut from one texture, keeping the source they share with it.
26873
+ *
26874
+ * @param state - The sync branch of the plugin state.
26875
+ * @param base - The texture that goes.
26876
+ */
26877
+ function releaseFrames$1(state, base) {
26878
+ for (const [frameKey, frame] of state.frames) {
26879
+ if (frame.base !== base) continue;
26880
+ frame.texture.destroy(false);
26881
+ state.frames.delete(frameKey);
26882
+ }
26883
+ }
26884
+ /**
26885
+ * Frees every crop. Works on the state alone, because `onStop` has no context.
26886
+ *
26887
+ * @param state - The sync branch of the plugin state.
26888
+ */
26889
+ function clearFrames(state) {
26890
+ for (const frame of state.frames.values()) frame.texture.destroy(false);
26891
+ state.frames.clear();
26892
+ }
26893
+ /**
26894
+ * Frees a texture, its source and the crops cut from it. A slice keeps the source: it belongs to
26895
+ * the page, which frees it. A source that goes lets go of its `change` listeners first, the ones
26896
+ * of its style too, so no bind group Pixi still keeps is told. A texture destroyed twice is a
26897
+ * no-op.
26898
+ *
26899
+ * @param state - The sync branch of the plugin state.
26900
+ * @param texture - The texture to free.
26901
+ */
26902
+ function destroyTexture(state, texture) {
26903
+ releaseFrames$1(state, texture);
26904
+ if (texture.destroyed) return;
26905
+ if (state.slices.has(texture)) {
26906
+ texture.destroy(false);
26907
+ return;
26908
+ }
26909
+ texture.source.style.removeAllListeners("change");
26910
+ texture.source.removeAllListeners("change");
26911
+ texture.destroy(true);
26912
+ }
26913
+ //#endregion
26914
+ //#region src/plugins/renderer/sync/leave.ts
26915
+ /** Label of the container the look hangs in, last on the stage. */
26916
+ const TOP_LABEL = "renderer.leave";
26917
+ /** Clock milliseconds of one turn of the loader. */
26918
+ const TURN_MS = 1e3;
26919
+ /** A whole turn, in radians. */
26920
+ const TURN$1 = 2 * Math.PI;
26921
+ /** The engine's ring: three quarters of a white circle 96 u across, 10 u wide, round ends, turned about its centre. */
26922
+ const RING = Object.freeze({
26923
+ kind: "arc",
26924
+ w: 96,
26925
+ h: 96,
26926
+ fill: 16777215,
26927
+ fillAlpha: 0,
26928
+ alpha: 1,
26929
+ radius: 0,
26930
+ stroke: 16777215,
26931
+ strokeWidth: 10,
26932
+ dash: 0,
26933
+ clip: false,
26934
+ sweep: .75,
26935
+ cap: "round"
26936
+ });
26937
+ /**
26938
+ * Makes the engine's objects of the picture, once per application: the top container with the veil and the frame, and
26939
+ * in the frame the holder of a lifted layer, the ring and the spinner. Nothing hangs on the stage yet.
26940
+ *
26941
+ * @param sctx - Domain context of the sync module.
26942
+ * @returns The objects, or `undefined` while nothing draws (inert, a lost device).
26943
+ */
26944
+ function objectsOf(sctx) {
26945
+ const leave = sctx.ctx.state.sync.leave;
26946
+ if (leave.objects !== void 0) return leave.objects;
26947
+ const pixi = sctx.deps.host.pixi();
26948
+ if (pixi === void 0 || sctx.deps.host.stage() === void 0) return void 0;
26949
+ const top = new pixi.Container();
26950
+ const veil = new pixi.Sprite(pixi.Texture.WHITE);
26951
+ const frame = new pixi.Container();
26952
+ const held = new pixi.Container();
26953
+ const ring = new pixi.Graphics();
26954
+ const spinner = new pixi.Sprite(pixi.Texture.WHITE);
26955
+ top.label = TOP_LABEL;
26956
+ veil.label = `${TOP_LABEL}.cover`;
26957
+ veil.anchor.set(0, 0);
26958
+ held.label = `${TOP_LABEL}.layer`;
26959
+ ring.label = `${TOP_LABEL}.ring`;
26960
+ paintShape(ring, RING, RING.stroke);
26961
+ ring.pivot.set(RING.w / 2, RING.h / 2);
26962
+ spinner.label = `${TOP_LABEL}.texture`;
26963
+ spinner.anchor.set(.5, .5);
26964
+ frame.addChild(held);
26965
+ frame.addChild(ring);
26966
+ frame.addChild(spinner);
26967
+ top.addChild(veil);
26968
+ top.addChild(frame);
26969
+ leave.objects = {
26970
+ top,
26971
+ veil,
26972
+ frame,
26973
+ held,
26974
+ ring,
26975
+ spinner
26976
+ };
26977
+ return leave.objects;
26978
+ }
26979
+ /**
26980
+ * What the loader of a look draws once due: a game texture, a layer above the veil, or the engine's ring. A texture key
26981
+ * no provider answers is warned once and draws the ring.
26982
+ *
26983
+ * @param sctx - Domain context of the sync module.
26984
+ * @param look - The look being shown.
26985
+ * @returns The loader's kind, and the texture of a `"texture"` loader.
26986
+ */
26987
+ function loaderOf$1(sctx, look) {
26988
+ const loader = look.loader;
26989
+ if (loader === void 0) return { loader: "none" };
26990
+ if (loader.texture === void 0) return { loader: loader.due === void 0 ? "ring" : "layer" };
26991
+ const texture = resolveTexture(sctx, loader.texture);
26992
+ if (texture !== void 0) return {
26993
+ loader: "texture",
26994
+ texture
26995
+ };
26996
+ warnMissing(sctx, loader.texture);
26997
+ return { loader: "ring" };
26998
+ }
26999
+ /**
27000
+ * Puts a layer the look drew above the veil back under the root, at the place of its layer: the layer list of the
27001
+ * scene orders the root's children. A layer the list of a swap took back already, or let go, is left alone.
27002
+ *
27003
+ * @param sctx - Domain context of the sync module.
27004
+ * @param run - The look whose layer goes back.
27005
+ */
27006
+ function unlift(sctx, run) {
27007
+ const lifted = run.lifted;
27008
+ if (lifted === void 0) return;
27009
+ delete run.lifted;
27010
+ const state = sctx.ctx.state.sync;
27011
+ if (lifted.parent !== state.leave.objects?.held) return;
27012
+ const name = run.layer;
27013
+ const at = state.layerList?.findIndex((spec) => spec.name === name) ?? -1;
27014
+ const root = state.root;
27015
+ state.passesVersion += 1;
27016
+ if (root === void 0 || at === -1) {
27017
+ detach$1(lifted);
27018
+ return;
27019
+ }
27020
+ root.addChildAt(lifted, Math.min(at, root.children.length));
27021
+ }
27022
+ /**
27023
+ * Takes the look down at once: its layer goes back, its objects leave the stage and are kept for the next look, and the
27024
+ * spinner lets the game's texture go. A frame is asked for, so the screen without it is drawn.
27025
+ *
27026
+ * @param sctx - Domain context of the sync module.
27027
+ */
27028
+ function takeDown(sctx) {
27029
+ const leave = sctx.ctx.state.sync.leave;
27030
+ const run = leave.run;
27031
+ if (run !== void 0) unlift(sctx, run);
27032
+ delete leave.run;
27033
+ const objects = leave.objects;
27034
+ if (objects !== void 0) {
27035
+ detach$1(objects.top);
27036
+ const white = placeholderTexture(sctx);
27037
+ if (white !== void 0) objects.spinner.texture = white;
27038
+ }
27039
+ sctx.deps.demand.request();
27040
+ }
27041
+ /**
27042
+ * Moves the veil's level on the clock: up to 1 over `inMs` before the swap, down to 0 over `outMs` after it.
27043
+ *
27044
+ * @param run - The running look.
27045
+ * @param now - The clock now, ms.
27046
+ * @returns True when the level moved: the frame is drawn.
27047
+ */
27048
+ function advance(run, now) {
27049
+ const cover = run.look.cover;
27050
+ const elapsed = Math.max(0, now - run.steppedAt);
27051
+ const before = run.level;
27052
+ run.steppedAt = now;
27053
+ if (cover === void 0) return false;
27054
+ if (run.landed) run.level = cover.outMs > 0 ? Math.max(0, before - elapsed / cover.outMs) : 0;
27055
+ else run.level = cover.inMs > 0 ? Math.min(1, before + elapsed / cover.inMs) : 1;
27056
+ return run.level !== before;
27057
+ }
27058
+ /**
27059
+ * Hangs the look last on the stage and over the canvas as it stands: the veil covers the screen, the frame carries the
27060
+ * root's transform, so what it holds lands where the scene's reference units do.
27061
+ *
27062
+ * @param sctx - Domain context of the sync module.
27063
+ * @param objects - The engine's objects.
27064
+ * @param stage - The stage of the application.
27065
+ * @param screen - The canvas in CSS pixels, the stage's own space.
27066
+ * @param screen.x - Its left edge.
27067
+ * @param screen.y - Its top edge.
27068
+ * @param screen.width - Its width.
27069
+ * @param screen.height - Its height.
27070
+ */
27071
+ function hang$1(sctx, objects, stage, screen) {
27072
+ if (stage.children.at(-1) !== objects.top) stage.addChild(objects.top);
27073
+ objects.veil.position.set(screen.x, screen.y);
27074
+ objects.veil.scale.set(screen.width, screen.height);
27075
+ sctx.deps.viewport.apply(objects.frame);
27076
+ }
27077
+ /**
27078
+ * Asks a `"layer"` loader's `due`, once, the first frame it is due: what it draws is made now, and it answers the
27079
+ * layer that holds it. No layer, or a throw (logged), draws the ring instead.
27080
+ *
27081
+ * @param sctx - Domain context of the sync module.
27082
+ * @param run - The running look, its loader due.
27083
+ */
27084
+ function ask(sctx, run) {
27085
+ run.asked = true;
27086
+ let layer;
27087
+ try {
27088
+ layer = run.look.loader?.due?.();
27089
+ } catch (error) {
27090
+ sctx.ctx.log.error("renderer: the loader of a scene being left threw", { error });
27091
+ }
27092
+ if (layer === void 0) run.loader = "ring";
27093
+ else run.layer = layer;
27094
+ }
27095
+ /**
27096
+ * Writes the veil of a running look: its colour, and its alpha at the level it stands at; hidden for a look without one.
27097
+ *
27098
+ * @param objects - The engine's objects.
27099
+ * @param run - The running look.
27100
+ */
27101
+ function paintVeil(objects, run) {
27102
+ const cover = run.look.cover;
27103
+ objects.veil.visible = cover !== void 0;
27104
+ if (cover === void 0) return;
27105
+ objects.veil.tint = cover.color;
27106
+ objects.veil.alpha = cover.alpha * run.level;
27107
+ }
27108
+ /**
27109
+ * Keeps the layer of a shown `"layer"` loader in the holder above the veil until the swap. A layer the scene does not
27110
+ * declare draws nothing.
27111
+ *
27112
+ * @param sctx - Domain context of the sync module.
27113
+ * @param objects - The engine's objects.
27114
+ * @param run - The running look, its loader shown.
27115
+ */
27116
+ function lift(sctx, objects, run) {
27117
+ const state = sctx.ctx.state.sync;
27118
+ const container = run.layer === void 0 ? void 0 : layerContainer(state, run.layer);
27119
+ if (container === void 0 || container.parent === objects.held) return;
27120
+ objects.held.addChild(container);
27121
+ run.lifted = container;
27122
+ state.passesVersion += 1;
27123
+ }
27124
+ /**
27125
+ * Turns the ring or the spinner at the centre of the frame: one turn a second from the switch start.
27126
+ *
27127
+ * @param objects - The engine's objects.
27128
+ * @param run - The running look, its loader shown.
27129
+ * @param now - The clock now, ms.
27130
+ * @param size - The frame in reference units.
27131
+ */
27132
+ function turn(objects, run, now, size) {
27133
+ const angle = (now - run.startedAt) % TURN_MS / TURN_MS * TURN$1;
27134
+ const turned = run.loader === "ring" ? objects.ring : objects.spinner;
27135
+ if (run.texture !== void 0 && objects.spinner.texture !== run.texture) objects.spinner.texture = run.texture;
27136
+ turned.position.set(size.width / 2, size.height / 2);
27137
+ turned.rotation = angle;
27138
+ }
27139
+ /**
27140
+ * Shows the loader once the switch took `afterMs`, until the swap: a layer loader asks its `due` then and lifts the
27141
+ * layer it answers above the veil; a ring or a texture turns. A loader that comes or goes moves the walks of `shown`,
27142
+ * so the loops of a lifted layer's views run while it shows.
27143
+ *
27144
+ * @param sctx - Domain context of the sync module.
27145
+ * @param objects - The engine's objects.
27146
+ * @param run - The running look.
27147
+ * @param now - The clock now, ms.
27148
+ * @returns True when the loader came, went or turns in this frame: the frame is drawn.
27149
+ */
27150
+ function paintLoader(sctx, objects, run, now) {
27151
+ const afterMs = run.look.loader?.afterMs ?? 0;
27152
+ const shown = !run.landed && run.loader !== "none" && now - run.startedAt >= afterMs;
27153
+ const due = shown && run.loader === "layer";
27154
+ if (due && !run.asked) ask(sctx, run);
27155
+ if (due) lift(sctx, objects, run);
27156
+ const toggled = shown !== run.shown;
27157
+ if (toggled) {
27158
+ run.shown = shown;
27159
+ sctx.ctx.state.sync.passesVersion += 1;
27160
+ }
27161
+ const isLayer = shown && run.loader === "layer";
27162
+ objects.held.visible = isLayer;
27163
+ objects.ring.visible = shown && run.loader === "ring";
27164
+ objects.spinner.visible = shown && run.loader === "texture";
27165
+ if (!shown || isLayer) return toggled;
27166
+ turn(objects, run, now, sctx.deps.viewport.size());
27167
+ return true;
27168
+ }
27169
+ /**
27170
+ * Shows a look from now, in place of one that runs: the veil fades in from where it stands (0, or the level of a veil
27171
+ * still fading out after the last swap), the loader waits for its `afterMs`. Inert, nothing is kept.
27172
+ *
27173
+ * @param sctx - Domain context of the sync module.
27174
+ * @param look - The veil and the loader, resolved.
27175
+ */
27176
+ function showLeave(sctx, look) {
27177
+ const state = sctx.ctx.state.sync;
27178
+ if (state.root === void 0) return;
27179
+ const before = state.leave.run;
27180
+ if (before !== void 0) unlift(sctx, before);
27181
+ const now = sctx.ctx.deps.clock.now();
27182
+ const level = before?.look.cover === void 0 ? 0 : before.level;
27183
+ const loader = loaderOf$1(sctx, look);
27184
+ state.leave.run = {
27185
+ look,
27186
+ startedAt: now,
27187
+ steppedAt: now,
27188
+ level,
27189
+ landed: false,
27190
+ asked: false,
27191
+ shown: false,
27192
+ ...loader
27193
+ };
27194
+ sctx.deps.demand.request();
27195
+ }
27196
+ /**
27197
+ * The swap came: the loader goes at once and the veil fades out over its `outMs` from where it stands; a look without
27198
+ * a veil, or with `outMs: 0`, is taken down at once. Nothing runs, or the swap was told already: nothing happens.
27199
+ *
27200
+ * @param sctx - Domain context of the sync module.
27201
+ */
27202
+ function landLeave(sctx) {
27203
+ const run = sctx.ctx.state.sync.leave.run;
27204
+ if (run === void 0 || run.landed) return;
27205
+ const cover = run.look.cover;
27206
+ if (cover === void 0 || cover.outMs <= 0) {
27207
+ takeDown(sctx);
27208
+ return;
27209
+ }
27210
+ unlift(sctx, run);
27211
+ run.landed = true;
27212
+ run.steppedAt = sctx.ctx.deps.clock.now();
27213
+ sctx.deps.demand.request();
27214
+ }
27215
+ /**
27216
+ * The switch ended without a swap: the veil and the loader go at once. Nothing runs: nothing happens.
27217
+ *
27218
+ * @param sctx - Domain context of the sync module.
27219
+ */
27220
+ function dropLeave(sctx) {
27221
+ if (sctx.ctx.state.sync.leave.run === void 0) return;
27222
+ takeDown(sctx);
27223
+ }
27224
+ /**
27225
+ * The look of this frame, before the decision: the veil's level from the clock, the loader once due, both placed over
27226
+ * the canvas as it stands, and a frame asked for while either moves. A look whose veil faded out after the swap is
27227
+ * taken down. One field read and no allocation while no look runs; a lost device leaves the look waiting for the
27228
+ * restore, which makes its objects again.
27229
+ *
27230
+ * @param sctx - Domain context of the sync module.
27231
+ */
27232
+ function stepLeave(sctx) {
27233
+ const run = sctx.ctx.state.sync.leave.run;
27234
+ if (run === void 0) return;
27235
+ const objects = objectsOf(sctx);
27236
+ const stage = sctx.deps.host.stage();
27237
+ const screen = sctx.deps.host.screen();
27238
+ if (objects === void 0 || stage === void 0 || screen === void 0) return;
27239
+ const now = sctx.ctx.deps.clock.now();
27240
+ const moved = advance(run, now);
27241
+ if (run.landed && run.level <= 0) {
27242
+ takeDown(sctx);
27243
+ return;
27244
+ }
27245
+ hang$1(sctx, objects, stage, screen);
27246
+ paintVeil(objects, run);
27247
+ const loading = paintLoader(sctx, objects, run, now);
27248
+ if (moved || loading) sctx.deps.demand.request();
27249
+ }
27250
+ /**
27251
+ * Forgets the objects of a lost application: they go with its stage, and so does a layer lifted above the veil. The
27252
+ * running look stays: the step makes its objects again on the restored device.
27253
+ *
27254
+ * @param state - The sync branch of the plugin state.
27255
+ */
27256
+ function forgetLeave$1(state) {
27257
+ delete state.leave.objects;
27258
+ if (state.leave.run !== void 0) delete state.leave.run.lifted;
27259
+ }
27260
+ /**
27261
+ * Drops the look and frees the engine's objects when the plugin stops. A lifted layer is the layer table's and the
27262
+ * spinner's texture is the game's: neither is freed with them.
27263
+ *
27264
+ * @param state - The sync branch of the plugin state.
27265
+ */
27266
+ function stopLeave(state) {
27267
+ const objects = state.leave.objects;
27268
+ delete state.leave.run;
27269
+ delete state.leave.objects;
27270
+ if (objects === void 0) return;
27271
+ objects.held.removeChildren();
27272
+ objects.top.destroy({ children: true });
27273
+ }
27274
+ //#endregion
26029
27275
  //#region src/plugins/renderer/sync/overlay-range.ts
26030
27276
  /** A range retaken for a mark in this many frames in a row is drawn live instead (O8.4; spec 23's `CHURN_LIMIT`). */
26031
27277
  const RETAKE_STREAK = 3;
@@ -26718,349 +27964,6 @@ function stopOverlays(state) {
26718
27964
  overlay.range = void 0;
26719
27965
  }
26720
27966
  //#endregion
26721
- //#region src/plugins/renderer/sync/shape-path.ts
26722
- /**
26723
- * @file renderer/sync — the path of a `Shape`: a rectangle, a rounded rectangle or a triangle,
26724
- * its fill, and its stroke, solid or dashed, or an arc, a stroke alone. A dashed stroke walks the
26725
- * outline (straight edges, and rounded corners sampled as arcs) and strokes one open line per dash.
26726
- * An arc runs on the circle inscribed in its box, from 12 o'clock clockwise.
26727
- */
26728
- /** Chords per rounded corner of a dashed outline: under 0.3 u off the arc at a 48 u radius. */
26729
- const CORNER_SAMPLES = 8;
26730
- /** The gap between two dashes, as a fraction of the dash length. */
26731
- const GAP_RATIO = .5;
26732
- /** Segments of a full ring: a ring of 28 u across strays under 0.03 u off its circle. */
26733
- const RING_SEGMENTS = 48;
26734
- /** The fewest segments of an arc that draws: a sliver still bends. */
26735
- const ARC_MIN_SEGMENTS = 2;
26736
- /** 12 o'clock, where an arc starts: Pixi's angles run clockwise from 3 o'clock. */
26737
- const TWELVE = -Math.PI / 2;
26738
- /**
26739
- * The corner radius the rectangle is drawn with: Pixi clamps it to half the shorter side.
26740
- *
26741
- * @param value - The shape value.
26742
- * @returns The radius in reference units, 0 for square corners.
26743
- * @example
26744
- * ```ts
26745
- * radiusOf({ ...Shape.defaults, w: 100, h: 20, radius: 40 }); // 10
26746
- * ```
26747
- */
26748
- function radiusOf(value) {
26749
- return Math.max(0, Math.min(value.radius, value.w / 2, value.h / 2));
26750
- }
26751
- /**
26752
- * The closed outline of a shape as points, clockwise from the top left (from the end of the top
26753
- * left corner on a rounded rectangle); the last point repeats the first.
26754
- *
26755
- * @param value - The shape value.
26756
- * @returns The points of the outline.
26757
- * @example
26758
- * ```ts
26759
- * outlineOf({ ...Shape.defaults, kind: "triangle", w: 60, h: 40 });
26760
- * // [{ x: 0, y: 0 }, { x: 60, y: 20 }, { x: 0, y: 40 }, { x: 0, y: 0 }]
26761
- * ```
26762
- */
26763
- function outlineOf(value) {
26764
- const { w, h } = value;
26765
- if (value.kind === "triangle") return [
26766
- {
26767
- x: 0,
26768
- y: 0
26769
- },
26770
- {
26771
- x: w,
26772
- y: h / 2
26773
- },
26774
- {
26775
- x: 0,
26776
- y: h
26777
- },
26778
- {
26779
- x: 0,
26780
- y: 0
26781
- }
26782
- ];
26783
- const radius = radiusOf(value);
26784
- if (radius === 0) return [
26785
- {
26786
- x: 0,
26787
- y: 0
26788
- },
26789
- {
26790
- x: w,
26791
- y: 0
26792
- },
26793
- {
26794
- x: w,
26795
- y: h
26796
- },
26797
- {
26798
- x: 0,
26799
- y: h
26800
- },
26801
- {
26802
- x: 0,
26803
- y: 0
26804
- }
26805
- ];
26806
- const corners = [
26807
- {
26808
- edge: {
26809
- x: w - radius,
26810
- y: 0
26811
- },
26812
- center: {
26813
- x: w - radius,
26814
- y: radius
26815
- }
26816
- },
26817
- {
26818
- edge: {
26819
- x: w,
26820
- y: h - radius
26821
- },
26822
- center: {
26823
- x: w - radius,
26824
- y: h - radius
26825
- }
26826
- },
26827
- {
26828
- edge: {
26829
- x: radius,
26830
- y: h
26831
- },
26832
- center: {
26833
- x: radius,
26834
- y: h - radius
26835
- }
26836
- },
26837
- {
26838
- edge: {
26839
- x: 0,
26840
- y: radius
26841
- },
26842
- center: {
26843
- x: radius,
26844
- y: radius
26845
- }
26846
- }
26847
- ];
26848
- const points = [{
26849
- x: radius,
26850
- y: 0
26851
- }];
26852
- for (const [index, { edge, center }] of corners.entries()) {
26853
- const from = -Math.PI / 2 + index * Math.PI / 2;
26854
- points.push(edge);
26855
- for (let sample = 1; sample <= CORNER_SAMPLES; sample += 1) {
26856
- const angle = from + sample * Math.PI / 2 / CORNER_SAMPLES;
26857
- points.push({
26858
- x: center.x + radius * Math.cos(angle),
26859
- y: center.y + radius * Math.sin(angle)
26860
- });
26861
- }
26862
- }
26863
- return points;
26864
- }
26865
- /**
26866
- * Adds a point to a line unless the line already ends there.
26867
- *
26868
- * @param line - The line.
26869
- * @param point - The point.
26870
- */
26871
- function extend(line, point) {
26872
- const last = line.at(-1);
26873
- if (last?.x !== point.x || last.y !== point.y) line.push(point);
26874
- }
26875
- /**
26876
- * Cuts a polyline into dashes of `dash` length with gaps of half a dash, starting with a dash at
26877
- * its first point. A dash bends round the corners it crosses; the last one may be shorter.
26878
- *
26879
- * @param outline - The points of the polyline.
26880
- * @param dash - The dash length, above 0.
26881
- * @returns One open line per dash.
26882
- * @example
26883
- * ```ts
26884
- * dashesOf([{ x: 0, y: 0 }, { x: 40, y: 0 }], 10);
26885
- * // [[{ x: 0, y: 0 }, { x: 10, y: 0 }], [{ x: 15, y: 0 }, { x: 25, y: 0 }], [{ x: 30, y: 0 }, { x: 40, y: 0 }]]
26886
- * ```
26887
- */
26888
- function dashesOf(outline, dash) {
26889
- const first = outline[0];
26890
- if (first === void 0) return [];
26891
- const dashes = [];
26892
- let line = [first];
26893
- let left = dash;
26894
- for (let index = 1; index < outline.length; index += 1) {
26895
- let from = outline[index - 1] ?? first;
26896
- const to = outline[index] ?? first;
26897
- let length = Math.hypot(to.x - from.x, to.y - from.y);
26898
- while (length > left) {
26899
- from = {
26900
- x: from.x + (to.x - from.x) * left / length,
26901
- y: from.y + (to.y - from.y) * left / length
26902
- };
26903
- length -= left;
26904
- if (line === void 0) {
26905
- line = [from];
26906
- left = dash;
26907
- } else {
26908
- extend(line, from);
26909
- dashes.push(line);
26910
- line = void 0;
26911
- left = dash * GAP_RATIO;
26912
- }
26913
- }
26914
- left -= length;
26915
- if (line !== void 0) extend(line, to);
26916
- }
26917
- if (line !== void 0 && line.length > 1) dashes.push(line);
26918
- return dashes;
26919
- }
26920
- /**
26921
- * Draws the path of a shape and fills it in a colour at the alpha given. At alpha 0 the path stays unfilled, so only
26922
- * a stroke shows. A clip mask is drawn here too, never dashed; the path of an arc here is its box, which is what it
26923
- * clips its children to. A fill-only shape is drawn white and coloured by the object's tint, so a colour change draws
26924
- * no path (spec 26 R2.1).
26925
- *
26926
- * @param object - The graphics to draw on.
26927
- * @param value - The shape value.
26928
- * @param fillAlpha - The alpha of the fill: the value's own for the shape, 1 for a clip mask.
26929
- * @param color - The colour of the fill: the value's own by default, white for a tinted shape.
26930
- */
26931
- function drawShapePath(object, value, fillAlpha, color = value.fill) {
26932
- object.clear();
26933
- if (value.kind === "triangle") object.moveTo(0, 0).lineTo(value.w, value.h / 2).lineTo(0, value.h).closePath();
26934
- else if (value.kind === "rect" && value.radius > 0) object.roundRect(0, 0, value.w, value.h, value.radius);
26935
- else object.rect(0, 0, value.w, value.h);
26936
- if (fillAlpha <= 0) return;
26937
- object.fill(fillAlpha < 1 ? {
26938
- color,
26939
- alpha: fillAlpha
26940
- } : { color });
26941
- }
26942
- /**
26943
- * Strokes the outline drawn by `drawShapePath`: solid over the same path, or, with a `dash`, as
26944
- * one open line per dash on a new path. Nothing without a stroke width.
26945
- *
26946
- * @param object - The graphics the shape was drawn on.
26947
- * @param value - The shape value.
26948
- */
26949
- function strokeShape(object, value) {
26950
- if (value.strokeWidth <= 0) return;
26951
- const style = {
26952
- color: value.stroke,
26953
- width: value.strokeWidth
26954
- };
26955
- if (value.dash <= 0) {
26956
- object.stroke(style);
26957
- return;
26958
- }
26959
- object.beginPath();
26960
- for (const [start, ...rest] of dashesOf(outlineOf(value), value.dash)) {
26961
- if (start === void 0) continue;
26962
- object.moveTo(start.x, start.y);
26963
- for (const point of rest) object.lineTo(point.x, point.y);
26964
- }
26965
- object.stroke(style);
26966
- }
26967
- /**
26968
- * The share of the circle an arc sweeps, clamped to 0..1: anything not above 0, `NaN` too, is 0; a whole ring when
26969
- * the value leaves it out.
26970
- *
26971
- * @param value - The shape value.
26972
- * @returns The share.
26973
- * @example
26974
- * ```ts
26975
- * sweepOf({ ...Shape.defaults, kind: "arc", sweep: 1.4 }); // 1
26976
- * ```
26977
- */
26978
- function sweepOf(value) {
26979
- const sweep = value.sweep ?? arcDefaults.sweep;
26980
- if (Number.isNaN(sweep) || sweep <= 0) return 0;
26981
- return Math.min(sweep, 1);
26982
- }
26983
- /**
26984
- * The radius of the circle an arc runs on: inscribed in the shorter side of its box, inside its stroke.
26985
- *
26986
- * @param value - The shape value.
26987
- * @returns The radius in reference units; 0 or below when the stroke leaves no circle.
26988
- * @example
26989
- * ```ts
26990
- * arcRadiusOf({ ...Shape.defaults, kind: "arc", w: 28, h: 40, strokeWidth: 2 }); // 13
26991
- * ```
26992
- */
26993
- function arcRadiusOf(value) {
26994
- return (Math.min(value.w, value.h) - value.strokeWidth) / 2;
26995
- }
26996
- /**
26997
- * Tells whether an arc draws nothing: no stroke width, no sweep, or no circle inside its stroke.
26998
- *
26999
- * @param value - The shape value, an arc.
27000
- * @returns True for an arc whose `Graphics` would hold no instruction.
27001
- * @example
27002
- * ```ts
27003
- * isEmptyArc({ ...Shape.defaults, kind: "arc", w: 28, h: 28, strokeWidth: 2, sweep: 0 }); // true
27004
- * ```
27005
- */
27006
- function isEmptyArc(value) {
27007
- return value.strokeWidth <= 0 || sweepOf(value) <= 0 || arcRadiusOf(value) <= 0;
27008
- }
27009
- /**
27010
- * Strokes an arc: from 12 o'clock clockwise over its share of the circle, in segments as many as its angle needs (48
27011
- * for a ring, at least 2), ended by its `cap`. A full ring is closed and has no ends. Nothing for an empty arc. Drawn
27012
- * only when the shape changed: never rebuilt per frame.
27013
- *
27014
- * @param object - The graphics, cleared.
27015
- * @param value - The shape value, an arc.
27016
- */
27017
- function strokeArc(object, value) {
27018
- if (isEmptyArc(value)) return;
27019
- const sweep = sweepOf(value);
27020
- const radius = arcRadiusOf(value);
27021
- const ring = sweep >= 1;
27022
- const segments = Math.max(ARC_MIN_SEGMENTS, Math.ceil(sweep * RING_SEGMENTS));
27023
- const step = sweep * 2 * Math.PI / segments;
27024
- const centreX = value.w / 2;
27025
- const centreY = value.h / 2;
27026
- const last = ring ? segments - 1 : segments;
27027
- object.moveTo(centreX, centreY - radius);
27028
- for (let index = 1; index <= last; index += 1) {
27029
- const angle = TWELVE + index * step;
27030
- object.lineTo(centreX + radius * Math.cos(angle), centreY + radius * Math.sin(angle));
27031
- }
27032
- if (ring) {
27033
- object.closePath();
27034
- object.stroke({
27035
- color: value.stroke,
27036
- width: value.strokeWidth
27037
- });
27038
- return;
27039
- }
27040
- object.stroke({
27041
- color: value.stroke,
27042
- width: value.strokeWidth,
27043
- cap: value.cap ?? arcDefaults.cap
27044
- });
27045
- }
27046
- /**
27047
- * Paints a shape on its graphics: an arc as its stroke alone, every other kind as its path, its fill in a colour at its
27048
- * own fill alpha, and its stroke. The one call that draws a shape's visual.
27049
- *
27050
- * @param object - The graphics.
27051
- * @param value - The shape value.
27052
- * @param color - The colour of the fill: white for a tinted shape, the value's own otherwise.
27053
- */
27054
- function paintShape(object, value, color) {
27055
- if (value.kind === "arc") {
27056
- object.clear();
27057
- strokeArc(object, value);
27058
- return;
27059
- }
27060
- drawShapePath(object, value, value.fillAlpha, color);
27061
- strokeShape(object, value);
27062
- }
27063
- //#endregion
27064
27967
  //#region src/plugins/renderer/sync/sizing.ts
27065
27968
  /**
27066
27969
  * Creates a layout to write into, every field present.
@@ -27196,299 +28099,6 @@ function layoutSpriteInto(out, texture, value) {
27196
28099
  return out;
27197
28100
  }
27198
28101
  //#endregion
27199
- //#region src/plugins/renderer/sync/textures.ts
27200
- /** The colour a missing texture is drawn in. */
27201
- const PLACEHOLDER_TINT = 16711935;
27202
- /**
27203
- * Creates an empty set. It lives in its own non-exported function because lint rule L5 refuses a
27204
- * collection built inside an exported declaration.
27205
- *
27206
- * @returns An empty set.
27207
- */
27208
- function emptySet$5() {
27209
- return /* @__PURE__ */ new Set();
27210
- }
27211
- /**
27212
- * Asks the providers from the newest to the oldest. The first answer wins, so a bundle loaded
27213
- * later shadows an older one.
27214
- *
27215
- * @param sctx - Domain context of the sync module.
27216
- * @param key - The asset key.
27217
- * @returns The texture, or `undefined` when nothing answered.
27218
- */
27219
- function resolveTexture(sctx, key) {
27220
- const providers = sctx.ctx.state.sync.providers;
27221
- for (let index = providers.length - 1; index >= 0; index -= 1) {
27222
- const texture = providers[index]?.(key);
27223
- if (texture !== void 0) return texture;
27224
- }
27225
- }
27226
- /**
27227
- * The 1x1 white texture the placeholder is drawn with.
27228
- *
27229
- * @param sctx - Domain context of the sync module.
27230
- * @returns The texture, or `undefined` while inert.
27231
- */
27232
- function placeholderTexture(sctx) {
27233
- return sctx.deps.host.pixi()?.Texture.WHITE;
27234
- }
27235
- /**
27236
- * Reports a key no provider answers. One key warns once, however many views use it.
27237
- *
27238
- * @param sctx - Domain context of the sync module.
27239
- * @param key - The asset key.
27240
- */
27241
- function warnMissing(sctx, key) {
27242
- const warned = sctx.ctx.state.sync.warned;
27243
- if (key === "" || warned.has(key)) return;
27244
- warned.add(key);
27245
- sctx.ctx.log.warn("renderer: no texture for asset key", { key });
27246
- }
27247
- /**
27248
- * Remembers that an entity draws a key, so `invalidate` finds it again.
27249
- *
27250
- * @param state - The sync branch of the plugin state.
27251
- * @param entity - The entity.
27252
- * @param key - The asset key.
27253
- */
27254
- function trackKey(state, entity, key) {
27255
- const users = state.byKey.get(key) ?? emptySet$5();
27256
- users.add(entity);
27257
- state.byKey.set(key, users);
27258
- }
27259
- /**
27260
- * Forgets that an entity draws a key.
27261
- *
27262
- * @param state - The sync branch of the plugin state.
27263
- * @param entity - The entity.
27264
- * @param key - The asset key.
27265
- */
27266
- function untrackKey(state, entity, key) {
27267
- const users = state.byKey.get(key);
27268
- if (users === void 0) return;
27269
- users.delete(entity);
27270
- if (users.size === 0) state.byKey.delete(key);
27271
- }
27272
- /**
27273
- * The Pixi module of a renderer that draws. A texture made while it does not would be lost with
27274
- * the device, so the member that asked throws.
27275
- *
27276
- * @param sctx - Domain context of the sync module.
27277
- * @param member - The `textures` member that asked, for the message.
27278
- * @returns The Pixi module.
27279
- * @throws {Error} When the renderer does not draw.
27280
- */
27281
- function drawingPixi(sctx, member) {
27282
- const pixi = sctx.deps.host.pixi();
27283
- if (pixi === void 0 || !sctx.deps.host.ready()) throw new Error(`[game] renderer.sync.textures.${member} needs a ready renderer.\n Check app.renderer.host.ready() first.`);
27284
- return pixi;
27285
- }
27286
- /**
27287
- * The default borders of a texture, from the four numbers `nine` gives.
27288
- *
27289
- * @param nine - Left, top, right and bottom in pixels.
27290
- * @returns The borders, as Pixi reads them.
27291
- * @example
27292
- * ```ts
27293
- * defaultBordersOf([24, 20, 16, 12]); // { left: 24, top: 20, right: 16, bottom: 12 }
27294
- * ```
27295
- */
27296
- function defaultBordersOf(nine) {
27297
- return {
27298
- left: nine[0],
27299
- top: nine[1],
27300
- right: nine[2],
27301
- bottom: nine[3]
27302
- };
27303
- }
27304
- /**
27305
- * Makes a Pixi texture out of a decoded image, on behalf of `assets`.
27306
- *
27307
- * @param sctx - Domain context of the sync module.
27308
- * @param image - The decoded image.
27309
- * @param options - Nine-slice borders in pixels, and whether Pixi generates the mip chain.
27310
- * @returns The new texture.
27311
- * @throws {Error} When the renderer does not draw.
27312
- */
27313
- function createTexture(sctx, image, options) {
27314
- const pixi = drawingPixi(sctx, "create");
27315
- const base = options?.mips === true ? pixi.Texture.from({
27316
- resource: image,
27317
- autoGenerateMipmaps: true
27318
- }) : pixi.Texture.from(image);
27319
- const nine = options?.nine;
27320
- if (nine === void 0) return base;
27321
- const bordered = new pixi.Texture({
27322
- source: base.source,
27323
- defaultBorders: defaultBordersOf(nine)
27324
- });
27325
- base.destroy(false);
27326
- return bordered;
27327
- }
27328
- /**
27329
- * Tells whether a frame lies inside a page of the given size. A negative number, or one that is
27330
- * not a number, lies outside.
27331
- *
27332
- * @param frame - The frame in page pixels.
27333
- * @param page - The size of the page.
27334
- * @param page.width - Width of the page in pixels.
27335
- * @param page.height - Height of the page in pixels.
27336
- * @returns True when the whole frame is on the page.
27337
- * @example
27338
- * ```ts
27339
- * frameFits({ x: 256, y: 384, width: 256, height: 128 }, { width: 512, height: 512 }); // true
27340
- * ```
27341
- */
27342
- function frameFits(frame, page) {
27343
- return frame.x >= 0 && frame.y >= 0 && frame.width >= 0 && frame.height >= 0 && frame.x + frame.width <= page.width && frame.y + frame.height <= page.height;
27344
- }
27345
- /**
27346
- * Cuts a texture out of an atlas page, on behalf of `assets`. The slice shares the page's source,
27347
- * its frame is offset by the page's own frame, and it is marked as a slice so `destroyTexture`
27348
- * frees only the wrapper.
27349
- *
27350
- * @param sctx - Domain context of the sync module.
27351
- * @param page - The page texture.
27352
- * @param frame - The frame in page pixels.
27353
- * @param options - Nine-slice borders, in pixels.
27354
- * @returns The slice.
27355
- * @throws {Error} When the renderer does not draw, and when the frame does not fit in the page.
27356
- */
27357
- function sliceTexture(sctx, page, frame, options) {
27358
- const pixi = drawingPixi(sctx, "slice");
27359
- if (!frameFits(frame, page)) throw new Error(`[game] renderer.sync.textures.slice: frame ${frame.x},${frame.y} ${frame.width}x${frame.height} is outside page ${page.width}x${page.height}.\n Run "bun run assets:pack".`);
27360
- const nine = options?.nine;
27361
- const slice = new pixi.Texture({
27362
- source: page.source,
27363
- frame: new pixi.Rectangle(page.frame.x + frame.x, page.frame.y + frame.y, frame.width, frame.height),
27364
- ...nine === void 0 ? {} : { defaultBorders: defaultBordersOf(nine) }
27365
- });
27366
- sctx.ctx.state.sync.slices.add(slice);
27367
- return slice;
27368
- }
27369
- /**
27370
- * The cache key of one crop: the asset key and the box it covers.
27371
- *
27372
- * @param key - The asset key.
27373
- * @param width - Width of the box.
27374
- * @param height - Height of the box.
27375
- * @returns The key of the crop.
27376
- * @example
27377
- * ```ts
27378
- * frameKeyOf("board.bg", 1080, 1920); // "board.bg@1080x1920"
27379
- * ```
27380
- */
27381
- function frameKeyOf(key, width, height) {
27382
- return `${key}@${width}x${height}`;
27383
- }
27384
- /**
27385
- * The texture a `"cover"` sprite draws: the part of `base` inside `crop`, sharing its source. One
27386
- * crop per asset key and box, shared by every entity that shows it; the entity is added to its
27387
- * users. A key that answers a new base has been invalidated, so every view of it resolves again
27388
- * in this pass and joins the new crop; the old crop goes at once.
27389
- *
27390
- * @param sctx - Domain context of the sync module.
27391
- * @param entity - The entity that shows the crop.
27392
- * @param key - The asset key the base was resolved for.
27393
- * @param base - The texture the key resolves to.
27394
- * @param box - The box the sprite covers, which names the crop.
27395
- * @param box.width - Width of the box.
27396
- * @param box.height - Height of the box.
27397
- * @param crop - The part of the base to show, in texture pixels.
27398
- * @returns The crop and its `frames` key, or the base and `""` while inert.
27399
- */
27400
- function coverTexture(sctx, entity, key, base, box, crop) {
27401
- const frames = sctx.ctx.state.sync.frames;
27402
- const frameKey = frameKeyOf(key, box.width, box.height);
27403
- const cached = frames.get(frameKey);
27404
- if (cached?.base === base) {
27405
- cached.users.add(entity);
27406
- return {
27407
- texture: cached.texture,
27408
- frameKey
27409
- };
27410
- }
27411
- const pixi = sctx.deps.host.pixi();
27412
- if (pixi === void 0) return {
27413
- texture: base,
27414
- frameKey: ""
27415
- };
27416
- if (cached !== void 0) cached.texture.destroy(false);
27417
- const texture = new pixi.Texture({
27418
- source: base.source,
27419
- frame: new pixi.Rectangle(base.frame.x + crop.x, base.frame.y + crop.y, crop.width, crop.height)
27420
- });
27421
- const users = emptySet$5();
27422
- users.add(entity);
27423
- frames.set(frameKey, {
27424
- base,
27425
- texture,
27426
- users
27427
- });
27428
- return {
27429
- texture,
27430
- frameKey
27431
- };
27432
- }
27433
- /**
27434
- * Lets an entity go of the crop it showed. The last user frees the crop, keeping the source it
27435
- * shares with its base.
27436
- *
27437
- * @param state - The sync branch of the plugin state.
27438
- * @param entity - The entity that no longer shows it.
27439
- * @param frameKey - The `frames` key of the crop; `""` for none.
27440
- */
27441
- function releaseCover(state, entity, frameKey) {
27442
- const frame = state.frames.get(frameKey);
27443
- if (frame === void 0) return;
27444
- frame.users.delete(entity);
27445
- if (frame.users.size > 0) return;
27446
- frame.texture.destroy(false);
27447
- state.frames.delete(frameKey);
27448
- }
27449
- /**
27450
- * Frees the crops cut from one texture, keeping the source they share with it.
27451
- *
27452
- * @param state - The sync branch of the plugin state.
27453
- * @param base - The texture that goes.
27454
- */
27455
- function releaseFrames$1(state, base) {
27456
- for (const [frameKey, frame] of state.frames) {
27457
- if (frame.base !== base) continue;
27458
- frame.texture.destroy(false);
27459
- state.frames.delete(frameKey);
27460
- }
27461
- }
27462
- /**
27463
- * Frees every crop. Works on the state alone, because `onStop` has no context.
27464
- *
27465
- * @param state - The sync branch of the plugin state.
27466
- */
27467
- function clearFrames(state) {
27468
- for (const frame of state.frames.values()) frame.texture.destroy(false);
27469
- state.frames.clear();
27470
- }
27471
- /**
27472
- * Frees a texture, its source and the crops cut from it. A slice keeps the source: it belongs to
27473
- * the page, which frees it. A source that goes lets go of its `change` listeners first, the ones
27474
- * of its style too, so no bind group Pixi still keeps is told. A texture destroyed twice is a
27475
- * no-op.
27476
- *
27477
- * @param state - The sync branch of the plugin state.
27478
- * @param texture - The texture to free.
27479
- */
27480
- function destroyTexture(state, texture) {
27481
- releaseFrames$1(state, texture);
27482
- if (texture.destroyed) return;
27483
- if (state.slices.has(texture)) {
27484
- texture.destroy(false);
27485
- return;
27486
- }
27487
- texture.source.style.removeAllListeners("change");
27488
- texture.source.removeAllListeners("change");
27489
- texture.destroy(true);
27490
- }
27491
- //#endregion
27492
28102
  //#region src/plugins/renderer/sync/upload.ts
27493
28103
  /** The calls of a source nobody waits for: none. */
27494
28104
  const NOBODY = Object.freeze([]);
@@ -28875,6 +29485,7 @@ function stopSync(state) {
28875
29485
  state.redraws = 0;
28876
29486
  state.overdraw.listeners.length = 0;
28877
29487
  clearLayers(state);
29488
+ stopLeave(state);
28878
29489
  destroyPools(state);
28879
29490
  state.root?.destroy({ children: true });
28880
29491
  state.root = void 0;
@@ -29044,6 +29655,7 @@ function renderFrame(ctx, modules) {
29044
29655
  modules.sync.stepFilters();
29045
29656
  modules.sync.stepOverlays();
29046
29657
  modules.sync.drainOverlayEvents(modules.monitor);
29658
+ modules.sync.stepLeave();
29047
29659
  modules.kept.collect();
29048
29660
  const overdraw = modules.sync.overdrawing();
29049
29661
  if (overdraw) modules.demand.request();
@@ -29847,7 +30459,7 @@ function boundsOf(sctx, entity) {
29847
30459
  * @param entity - The entity.
29848
30460
  * @returns The layer name, or `undefined` when the root has no `Layer`.
29849
30461
  */
29850
- function layerOf(sctx, entity) {
30462
+ function layerOf$1(sctx, entity) {
29851
30463
  const ecs = sctx.ctx.deps.world.ecs;
29852
30464
  let top = entity;
29853
30465
  for (let depth = 0; depth < MAX_DEPTH$3; depth += 1) {
@@ -30474,7 +31086,7 @@ function stepParts(sctx) {
30474
31086
  const view = state.views.get(entity);
30475
31087
  if (view === void 0) continue;
30476
31088
  const key = ecs.get(entity, HudPart)?.key ?? "";
30477
- if (hud === void 0 || layerOf(sctx, entity) !== hud) {
31089
+ if (hud === void 0 || layerOf$1(sctx, entity) !== hud) {
30478
31090
  warnOutside(sctx, entity, key);
30479
31091
  applyHud(sctx, entity, view, 1);
30480
31092
  continue;
@@ -31519,6 +32131,7 @@ function createSyncApi(ctx, deps) {
31519
32131
  destroyPools(state);
31520
32132
  clearLayers(state);
31521
32133
  forgetBuild(state);
32134
+ forgetLeave$1(state);
31522
32135
  state.root = void 0;
31523
32136
  state.passesVersion += 1;
31524
32137
  };
@@ -31612,11 +32225,17 @@ function createSyncApi(ctx, deps) {
31612
32225
  area: (entity, area) => setArea(sctx, entity, area),
31613
32226
  warm: (filter) => warmFilter(sctx, filter)
31614
32227
  },
32228
+ leave: {
32229
+ show: (look) => showLeave(sctx, look),
32230
+ land: () => landLeave(sctx),
32231
+ drop: () => dropLeave(sctx)
32232
+ },
31615
32233
  renderPasses: () => renderPassesOf(state, ctx.state.kept),
31616
32234
  root: () => state.root,
31617
32235
  layerContainer: (name) => layerContainer(state, name),
31618
- layerOf: (entity) => layerOf(sctx, entity),
32236
+ layerOf: (entity) => layerOf$1(sctx, entity),
31619
32237
  stepOverlays: () => stepOverlays(sctx),
32238
+ stepLeave: () => stepLeave(sctx),
31620
32239
  stepFilters: () => applySwitched(state, deps.kept),
31621
32240
  drainOverlayEvents: (monitor) => drainOverlayEvents(sctx, monitor),
31622
32241
  warmStep: () => warmStep(sctx),
@@ -32205,6 +32824,7 @@ function exposeSync(sync) {
32205
32824
  fonts: sync.fonts,
32206
32825
  debug: sync.debug,
32207
32826
  filters: sync.filters,
32827
+ leave: sync.leave,
32208
32828
  renderPasses: sync.renderPasses,
32209
32829
  loopsHeld: sync.loopsHeld,
32210
32830
  displayOf: sync.displayOf
@@ -32446,8 +33066,8 @@ function createAsideQueue() {
32446
33066
  }
32447
33067
  /**
32448
33068
  * Creates the initial sync state: no root, no layers, no view, no orphan, no pool, no provider, no filter, no
32449
- * filter area, no slice, no hidden mask, no overlay, no warm-up, no upload, no redraw counted, no unseen walk kept and
32450
- * no scene build.
33069
+ * filter area, no slice, no hidden mask, no overlay, no warm-up, no upload, no redraw counted, no unseen walk kept,
33070
+ * no scene build and no picture of a scene being left.
32451
33071
  *
32452
33072
  * @param debug - The debug switches of the config; copied, so the frozen config stays untouched.
32453
33073
  * @returns The sync branch of the plugin state.
@@ -32512,6 +33132,7 @@ function createSyncState(debug) {
32512
33132
  count: 0,
32513
33133
  reported: false
32514
33134
  },
33135
+ leave: {},
32515
33136
  cleanups: []
32516
33137
  };
32517
33138
  }
@@ -35179,7 +35800,7 @@ const NO_ANIMATION_ID = "[game] A play effect carries no animation id.\n Build
35179
35800
  * isRecord([1, 2]); // false
35180
35801
  * ```
35181
35802
  */
35182
- function isRecord$3(value) {
35803
+ function isRecord$4(value) {
35183
35804
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
35184
35805
  }
35185
35806
  /**
@@ -35304,7 +35925,7 @@ function stepAnim(actx, rt, time) {
35304
35925
  * @returns True when it carries an id and a build function.
35305
35926
  */
35306
35927
  function isAnimation(entry) {
35307
- return isRecord$3(entry) && typeof entry.id === "string" && typeof entry.build === "function";
35928
+ return isRecord$4(entry) && typeof entry.id === "string" && typeof entry.build === "function";
35308
35929
  }
35309
35930
  /**
35310
35931
  * Reads the `animations` of every feature into the registry, in feature order.
@@ -35331,7 +35952,7 @@ function registerAnimations(actx) {
35331
35952
  */
35332
35953
  function readTarget$1(entry) {
35333
35954
  if (typeof entry === "number") return entry;
35334
- if (!isRecord$3(entry)) return void 0;
35955
+ if (!isRecord$4(entry)) return void 0;
35335
35956
  if (typeof entry.projection === "string" && typeof entry.key === "string") return {
35336
35957
  projection: entry.projection,
35337
35958
  key: entry.key
@@ -35347,7 +35968,7 @@ function readTarget$1(entry) {
35347
35968
  */
35348
35969
  function readSlots(value) {
35349
35970
  const slots = {};
35350
- if (!isRecord$3(value)) return slots;
35971
+ if (!isRecord$4(value)) return slots;
35351
35972
  for (const [name, entry] of Object.entries(value)) {
35352
35973
  if (Array.isArray(entry)) {
35353
35974
  slots[name] = entry.map((item) => readTarget$1(item)).filter((target) => target !== void 0);
@@ -35365,7 +35986,7 @@ function readSlots(value) {
35365
35986
  * @returns The animation id and its slots, or `undefined` for a foreign payload.
35366
35987
  */
35367
35988
  function readPlayPayload(payload) {
35368
- if (!isRecord$3(payload) || typeof payload.animation !== "string") return void 0;
35989
+ if (!isRecord$4(payload) || typeof payload.animation !== "string") return void 0;
35369
35990
  return {
35370
35991
  animation: payload.animation,
35371
35992
  slots: readSlots(payload.slots)
@@ -35689,7 +36310,7 @@ function emptyManifest() {
35689
36310
  * @param value - Anything that came out of JSON.
35690
36311
  * @returns True for an object that is not an array.
35691
36312
  */
35692
- function isRecord$2(value) {
36313
+ function isRecord$3(value) {
35693
36314
  return typeof value === "object" && value !== null && !Array.isArray(value);
35694
36315
  }
35695
36316
  /**
@@ -35729,7 +36350,7 @@ function stringAt(source, key) {
35729
36350
  * @returns The four borders, or `undefined`.
35730
36351
  */
35731
36352
  function parseNine(value) {
35732
- if (!isRecord$2(value)) return void 0;
36353
+ if (!isRecord$3(value)) return void 0;
35733
36354
  return {
35734
36355
  left: numberAt(value, "left"),
35735
36356
  top: numberAt(value, "top"),
@@ -35744,7 +36365,7 @@ function parseNine(value) {
35744
36365
  * @returns The frame, or `undefined`.
35745
36366
  */
35746
36367
  function parseAtlas(value) {
35747
- if (!isRecord$2(value)) return void 0;
36368
+ if (!isRecord$3(value)) return void 0;
35748
36369
  return {
35749
36370
  page: stringAt(value, "page"),
35750
36371
  x: numberAt(value, "x"),
@@ -35771,7 +36392,7 @@ function parseKind(value) {
35771
36392
  */
35772
36393
  function parseFontPages(value) {
35773
36394
  if (!Array.isArray(value)) return void 0;
35774
- return value.filter((entry) => isRecord$2(entry)).map((entry) => ({
36395
+ return value.filter((entry) => isRecord$3(entry)).map((entry) => ({
35775
36396
  path: stringAt(entry, "path"),
35776
36397
  width: numberAt(entry, "width"),
35777
36398
  height: numberAt(entry, "height"),
@@ -35786,7 +36407,7 @@ function parseFontPages(value) {
35786
36407
  */
35787
36408
  function parseAtlasPages(value) {
35788
36409
  if (!Array.isArray(value)) return void 0;
35789
- return value.filter((entry) => isRecord$2(entry)).map((entry) => ({
36410
+ return value.filter((entry) => isRecord$3(entry)).map((entry) => ({
35790
36411
  id: stringAt(entry, "id"),
35791
36412
  path: stringAt(entry, "path"),
35792
36413
  width: numberAt(entry, "width"),
@@ -35802,7 +36423,7 @@ function parseAtlasPages(value) {
35802
36423
  * @returns The file, or `undefined` when the element is not an object.
35803
36424
  */
35804
36425
  function parseFile(raw) {
35805
- if (!isRecord$2(raw)) return void 0;
36426
+ if (!isRecord$3(raw)) return void 0;
35806
36427
  const kind = parseKind(raw.kind);
35807
36428
  const pages = parseFontPages(raw.pages);
35808
36429
  const nine = parseNine(raw.nine);
@@ -35873,7 +36494,7 @@ function textAt(source, key) {
35873
36494
  * @throws {Error} When the entry is not an object or its tier is unknown.
35874
36495
  */
35875
36496
  function parseBundle(name, raw) {
35876
- if (!isRecord$2(raw)) throw new Error(`[game] assets: bundle "${name}" of the manifest is not an object.\n Rebuild the manifest with "bun run assets:keys".`);
36497
+ if (!isRecord$3(raw)) throw new Error(`[game] assets: bundle "${name}" of the manifest is not an object.\n Rebuild the manifest with "bun run assets:keys".`);
35877
36498
  const files = (Array.isArray(raw.files) ? raw.files : []).map((entry) => parseFile(entry)).filter((file) => file !== void 0).toSorted((left, right) => left.key.localeCompare(right.key));
35878
36499
  const pages = parseAtlasPages(raw.pages);
35879
36500
  const parent = textAt(raw, "parent");
@@ -35914,9 +36535,9 @@ function parseVersion(value) {
35914
36535
  * ```
35915
36536
  */
35916
36537
  function parseManifest(raw) {
35917
- if (!isRecord$2(raw)) throw new Error("[game] assets: the manifest is not an object.\n Point \"manifest\" at a manifest.json, or pass the parsed object.");
36538
+ if (!isRecord$3(raw)) throw new Error("[game] assets: the manifest is not an object.\n Point \"manifest\" at a manifest.json, or pass the parsed object.");
35918
36539
  const version = parseVersion(raw.version);
35919
- const source = isRecord$2(raw.bundles) ? raw.bundles : {};
36540
+ const source = isRecord$3(raw.bundles) ? raw.bundles : {};
35920
36541
  const bundles = {};
35921
36542
  for (const name of Object.keys(source).toSorted((left, right) => left.localeCompare(right))) bundles[name] = parseBundle(name, source[name]);
35922
36543
  return {
@@ -39101,6 +39722,468 @@ function dropBuild(build) {
39101
39722
  build.resolve();
39102
39723
  }
39103
39724
  //#endregion
39725
+ //#region src/plugins/scenes/leave.ts
39726
+ /**
39727
+ * What a game gets when it writes nothing: black at 45 %, 150 ms in and out, the engine's ring once the switch took
39728
+ * 400 ms. The level under `config.leave`, and where a part written over `false` starts. `index.ts` writes the same
39729
+ * value inline as the plugin's default config; a test pins the two equal.
39730
+ */
39731
+ const FALLBACK_LEAVE = Object.freeze({
39732
+ cover: Object.freeze({
39733
+ color: 0,
39734
+ alpha: .45,
39735
+ inMs: 150,
39736
+ outMs: 150
39737
+ }),
39738
+ loader: Object.freeze({ afterMs: 400 })
39739
+ });
39740
+ /**
39741
+ * Merges one level's veil over the one below, field by field. `false` turns it off; a veil written over `false`
39742
+ * starts from the engine's.
39743
+ *
39744
+ * @param below - The veil of the levels below, or `false`.
39745
+ * @param over - What this level writes: nothing, `false` or fields.
39746
+ * @returns The veil, or `false`.
39747
+ * @example
39748
+ * ```ts
39749
+ * coverOver(false, { alpha: 0.6 }); // { color: 0, alpha: 0.6, inMs: 150, outMs: 150 }
39750
+ * ```
39751
+ */
39752
+ function coverOver(below, over) {
39753
+ if (over === void 0) return below;
39754
+ if (over === false) return false;
39755
+ const base = below === false ? FALLBACK_LEAVE.cover : below;
39756
+ return {
39757
+ color: over.color ?? base.color,
39758
+ alpha: over.alpha ?? base.alpha,
39759
+ inMs: over.inMs ?? base.inMs,
39760
+ outMs: over.outMs ?? base.outMs
39761
+ };
39762
+ }
39763
+ /**
39764
+ * The look of a loader: its projection, else its texture, else nothing (the engine's ring).
39765
+ *
39766
+ * @param loader - A level that names a look, or the level below.
39767
+ * @returns At most one of the two fields.
39768
+ * @example
39769
+ * ```ts
39770
+ * lookOf({ texture: "ui.loader.ring", projection: "loading" }); // { projection: "loading" }
39771
+ * ```
39772
+ */
39773
+ function lookOf(loader) {
39774
+ if (loader.projection !== void 0) return { projection: loader.projection };
39775
+ if (loader.texture !== void 0) return { texture: loader.texture };
39776
+ return {};
39777
+ }
39778
+ /**
39779
+ * Merges one level's loader over the one below. `afterMs` is a field of its own; `texture` and `projection` are one
39780
+ * choice: a level that names either replaces both of the level below. `false` turns it off; a loader written over
39781
+ * `false` starts from the engine's.
39782
+ *
39783
+ * @param below - The loader of the levels below, or `false`.
39784
+ * @param over - What this level writes: nothing, `false` or fields.
39785
+ * @returns The loader, or `false`.
39786
+ * @example
39787
+ * ```ts
39788
+ * loaderOver({ afterMs: 400, projection: "loading" }, { texture: "ui.loader.ring" }); // { afterMs: 400, texture: "ui.loader.ring" }
39789
+ * ```
39790
+ */
39791
+ function loaderOver(below, over) {
39792
+ if (over === void 0) return below;
39793
+ if (over === false) return false;
39794
+ const base = below === false ? FALLBACK_LEAVE.loader : below;
39795
+ const named = over.texture !== void 0 || over.projection !== void 0;
39796
+ return {
39797
+ afterMs: over.afterMs ?? base.afterMs,
39798
+ ...lookOf(named ? over : base)
39799
+ };
39800
+ }
39801
+ /**
39802
+ * Resolves what a switch shows over the scene being left: the scene's `leave` over the config's over the engine's,
39803
+ * field by field. `false` at a level turns both parts off there.
39804
+ *
39805
+ * @param config - `config.leave`.
39806
+ * @param scene - The `leave` of the scene being left.
39807
+ * @returns Both parts, each resolved or `false`; `undefined` when both are off.
39808
+ * @example
39809
+ * ```ts
39810
+ * leaveOf({ loader: false }, { cover: { alpha: 0.6 } }); // { cover: { color: 0, alpha: 0.6, inMs: 150, outMs: 150 }, loader: false }
39811
+ * ```
39812
+ */
39813
+ function leaveOf(config, scene) {
39814
+ let cover = FALLBACK_LEAVE.cover;
39815
+ let loader = FALLBACK_LEAVE.loader;
39816
+ for (const level of [config, scene]) if (level === false) {
39817
+ cover = false;
39818
+ loader = false;
39819
+ } else if (level !== void 0) {
39820
+ cover = coverOver(cover, level.cover);
39821
+ loader = loaderOver(loader, level.loader);
39822
+ }
39823
+ if (cover === false && loader === false) return void 0;
39824
+ return {
39825
+ cover,
39826
+ loader
39827
+ };
39828
+ }
39829
+ /**
39830
+ * The layer a mounted projection's views draw in, read from its first view.
39831
+ *
39832
+ * @param ctx - Domain context of the plugin.
39833
+ * @param name - The projection.
39834
+ * @returns The layer, or `undefined` while it has no view.
39835
+ */
39836
+ function layerOf(ctx, name) {
39837
+ const { projection, ecs } = ctx.deps.world;
39838
+ const entity = projection.entitiesOf(name)[0];
39839
+ return entity === void 0 ? void 0 : ecs.get(entity, Layer)?.name;
39840
+ }
39841
+ /**
39842
+ * Unmounts the loader projection a switch mounted, once: one it did not mount is left alone.
39843
+ *
39844
+ * @param ctx - Domain context of the plugin.
39845
+ * @param leaving - The look of the switch.
39846
+ */
39847
+ function unmountLoader(ctx, leaving) {
39848
+ const name = leaving.projection;
39849
+ if (name === void 0) return;
39850
+ delete leaving.projection;
39851
+ ctx.deps.world.projection.unmount([name]);
39852
+ }
39853
+ /**
39854
+ * Mounts a loader projection for the switch, unless it is mounted already (by another owner): such a one is drawn as it
39855
+ * is and never unmounted by the switch. A mount that throws (an unknown name, a layer the scene being left does not
39856
+ * declare) is warned.
39857
+ *
39858
+ * @param ctx - Domain context of the plugin.
39859
+ * @param name - The loader projection.
39860
+ * @param from - The scene being left.
39861
+ * @param leaving - The look of the switch: it keeps the name to unmount at the end.
39862
+ * @returns False when the mount threw.
39863
+ */
39864
+ function mountOnce(ctx, name, from, leaving) {
39865
+ const projection = ctx.deps.world.projection;
39866
+ if (projection.entitiesOf(name).length > 0) return true;
39867
+ try {
39868
+ projection.mount([name], ctx.state.owner);
39869
+ } catch (error) {
39870
+ ctx.log.warn("scenes:loader-unmounted", {
39871
+ projection: name,
39872
+ scene: from.id,
39873
+ error
39874
+ });
39875
+ return false;
39876
+ }
39877
+ leaving.projection = name;
39878
+ return true;
39879
+ }
39880
+ /**
39881
+ * Tells whether a projection of the scene being left draws in a layer: lifted above the veil, that layer would take
39882
+ * the scene's own views with it.
39883
+ *
39884
+ * @param ctx - Domain context of the plugin.
39885
+ * @param from - The scene being left.
39886
+ * @param layer - The layer of the loader.
39887
+ * @returns True when one of its projections has a view in that layer.
39888
+ */
39889
+ function sharesLayer(ctx, from, layer) {
39890
+ return from.projections.some((other) => layerOf(ctx, other) === layer);
39891
+ }
39892
+ /**
39893
+ * Mounts a loader projection once its loader is due, called by the renderer in that frame, and answers the layer its
39894
+ * views draw in, which the renderer draws above the veil. A mount that throws, a layer a projection of the scene being
39895
+ * left draws in too, and a projection with no view give the engine's ring. A switch that ended meanwhile mounts
39896
+ * nothing.
39897
+ *
39898
+ * @param ctx - Domain context of the plugin.
39899
+ * @param name - The loader projection.
39900
+ * @param from - The scene being left.
39901
+ * @param leaving - The look of the switch: it keeps the name to unmount at the end.
39902
+ * @returns The layer, or `undefined` for the ring.
39903
+ */
39904
+ function mountLoader(ctx, name, from, leaving) {
39905
+ if (leaving.done || !mountOnce(ctx, name, from, leaving)) return void 0;
39906
+ const layer = layerOf(ctx, name);
39907
+ if (layer === void 0 || !sharesLayer(ctx, from, layer)) return layer;
39908
+ ctx.log.warn("scenes:loader-layer-shared", {
39909
+ projection: name,
39910
+ layer,
39911
+ scene: from.id
39912
+ });
39913
+ unmountLoader(ctx, leaving);
39914
+ }
39915
+ /**
39916
+ * The renderer's loader of a resolved one: a texture as it is, nothing for the ring, and for a projection the `due`
39917
+ * the renderer calls once the loader is due, which mounts it then: a switch done sooner never runs its `from` and
39918
+ * `view`. A projection a scene of the switch lists is no loader: warned, the ring.
39919
+ *
39920
+ * @param ctx - Domain context of the plugin.
39921
+ * @param loader - The resolved loader.
39922
+ * @param between - The scene being left and the scene being switched to.
39923
+ * @param between.from - The scene being left.
39924
+ * @param between.to - The scene being switched to.
39925
+ * @param leaving - The look of the switch.
39926
+ * @returns What the renderer draws.
39927
+ */
39928
+ function loaderLook(ctx, loader, between, leaving) {
39929
+ const { afterMs, texture, projection } = loader;
39930
+ const { from, to } = between;
39931
+ if (texture !== void 0) return {
39932
+ afterMs,
39933
+ texture
39934
+ };
39935
+ if (projection === void 0) return { afterMs };
39936
+ if (from.projections.includes(projection) || to.projections.includes(projection)) {
39937
+ ctx.log.warn("scenes:loader-listed", {
39938
+ projection,
39939
+ from: from.id,
39940
+ to: to.id
39941
+ });
39942
+ return { afterMs };
39943
+ }
39944
+ return {
39945
+ afterMs,
39946
+ due: () => mountLoader(ctx, projection, from, leaving)
39947
+ };
39948
+ }
39949
+ /**
39950
+ * Tells whether a switch shows anything over the scene being left: a real change of scene, a live run that was not
39951
+ * aborted, a world that runs live, a drawing renderer. The first scene of a run, a fast walk, a world walking fast or
39952
+ * paused and a headless app show nothing.
39953
+ *
39954
+ * @param ctx - Domain context of the plugin.
39955
+ * @param run - The runner's mode and signal.
39956
+ * @returns The scene being left, or `undefined` when nothing is shown.
39957
+ */
39958
+ function leftScene(ctx, run) {
39959
+ const current = ctx.state.current;
39960
+ if (current === void 0 || run.mode !== "live" || run.signal.aborted) return void 0;
39961
+ if (ctx.deps.world.ecs.mode() !== "live" || !ctx.deps.renderer.host.ready()) return void 0;
39962
+ return ctx.state.scenes.get(current);
39963
+ }
39964
+ /**
39965
+ * Shows the veil and the loader of a switch that starts now, before its bundles are awaited: the scene being left
39966
+ * decides. A loader projection is mounted only once its loader is due. An abort of the node drops both at once, from
39967
+ * its listener.
39968
+ *
39969
+ * @param ctx - Domain context of the plugin.
39970
+ * @param scene - The scene being switched to.
39971
+ * @param run - The runner's mode and signal.
39972
+ * @returns The look of the switch, or `undefined` when it shows nothing.
39973
+ */
39974
+ function startLeave(ctx, scene, run) {
39975
+ const from = leftScene(ctx, run);
39976
+ if (from === void 0) return void 0;
39977
+ const leave = leaveOf(ctx.config.leave, from.leave);
39978
+ if (leave === void 0) return void 0;
39979
+ const listening = new AbortController();
39980
+ const leaving = {
39981
+ done: false,
39982
+ off: () => listening.abort()
39983
+ };
39984
+ const look = {};
39985
+ if (leave.cover !== false) look.cover = leave.cover;
39986
+ if (leave.loader !== false) look.loader = loaderLook(ctx, leave.loader, {
39987
+ from,
39988
+ to: scene
39989
+ }, leaving);
39990
+ ctx.deps.renderer.sync.leave.show(look);
39991
+ run.signal.addEventListener("abort", () => endLeave(ctx, leaving, "drop"), {
39992
+ once: true,
39993
+ signal: listening.signal
39994
+ });
39995
+ ctx.state.leaving = leaving;
39996
+ return leaving;
39997
+ }
39998
+ /**
39999
+ * Ends the look of a switch once: `"land"` at the swap (the loader goes, the veil fades out over the next scene),
40000
+ * `"drop"` on an abort or a throw (both go at once). A loader projection mounted for the wait is unmounted. A look
40001
+ * ended already, or a switch that showed nothing, is left alone.
40002
+ *
40003
+ * @param ctx - Domain context of the plugin.
40004
+ * @param leaving - The look of the switch.
40005
+ * @param how - `"land"` after the swap, `"drop"` without one.
40006
+ */
40007
+ function endLeave(ctx, leaving, how) {
40008
+ if (leaving === void 0 || leaving.done) return;
40009
+ leaving.done = true;
40010
+ leaving.off();
40011
+ if (ctx.state.leaving === leaving) delete ctx.state.leaving;
40012
+ const leave = ctx.deps.renderer.sync.leave;
40013
+ if (how === "land") leave.land();
40014
+ else leave.drop();
40015
+ unmountLoader(ctx, leaving);
40016
+ }
40017
+ /**
40018
+ * Forgets the look of a running switch when the plugin stops: its abort listener goes and a later end does nothing.
40019
+ * Nothing else is called: a teardown context may not call another plugin; the renderer drops the look at its own stop,
40020
+ * and `world.onStop` the loader projection.
40021
+ *
40022
+ * @param state - The plugin state.
40023
+ */
40024
+ function forgetLeave(state) {
40025
+ const leaving = state.leaving;
40026
+ if (leaving === void 0) return;
40027
+ leaving.done = true;
40028
+ leaving.off();
40029
+ delete state.leaving;
40030
+ }
40031
+ //#endregion
40032
+ //#region src/plugins/scenes/leave-check.ts
40033
+ /** The largest colour, `0xffffff`. */
40034
+ const MAX_COLOR = 16777215;
40035
+ /**
40036
+ * Tells whether a value is a finite number of at least 0: a duration.
40037
+ *
40038
+ * @param value - The value.
40039
+ * @returns True for a finite number `>= 0`.
40040
+ * @example
40041
+ * ```ts
40042
+ * isDuration(-1); // false
40043
+ * ```
40044
+ */
40045
+ function isDuration(value) {
40046
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
40047
+ }
40048
+ /**
40049
+ * Tells whether a value is an alpha: a number from 0 to 1.
40050
+ *
40051
+ * @param value - The value.
40052
+ * @returns True for a number in `[0, 1]`.
40053
+ * @example
40054
+ * ```ts
40055
+ * isAlpha(1.2); // false
40056
+ * ```
40057
+ */
40058
+ function isAlpha(value) {
40059
+ return typeof value === "number" && value >= 0 && value <= 1;
40060
+ }
40061
+ /**
40062
+ * Tells whether a value is a colour `0xRRGGBB`: a whole number from 0 to `0xffffff`.
40063
+ *
40064
+ * @param value - The value.
40065
+ * @returns True for a colour.
40066
+ * @example
40067
+ * ```ts
40068
+ * isColor(0x0b1020); // true
40069
+ * ```
40070
+ */
40071
+ function isColor$1(value) {
40072
+ return Number.isInteger(value) && typeof value === "number" && value >= 0 && value <= MAX_COLOR;
40073
+ }
40074
+ /**
40075
+ * Tells whether a value is a name: a string that is not empty.
40076
+ *
40077
+ * @param value - The value.
40078
+ * @returns True for a string with at least one character.
40079
+ * @example
40080
+ * ```ts
40081
+ * isName(""); // false
40082
+ * ```
40083
+ */
40084
+ function isName(value) {
40085
+ return typeof value === "string" && value.length > 0;
40086
+ }
40087
+ /**
40088
+ * Tells whether a value is a plain object a field can be read from.
40089
+ *
40090
+ * @param value - The value.
40091
+ * @returns True for an object that is no array and not `null`.
40092
+ * @example
40093
+ * ```ts
40094
+ * isRecord([]); // false
40095
+ * ```
40096
+ */
40097
+ function isRecord$2(value) {
40098
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40099
+ }
40100
+ /** The fields of a veil. */
40101
+ const COVER_RULES = Object.freeze({
40102
+ color: {
40103
+ ok: isColor$1,
40104
+ hint: "a colour from 0x000000 to 0xffffff"
40105
+ },
40106
+ alpha: {
40107
+ ok: isAlpha,
40108
+ hint: "the alpha the veil fades in to, a number from 0 to 1"
40109
+ },
40110
+ inMs: {
40111
+ ok: isDuration,
40112
+ hint: "the milliseconds of the fade-in, 0 or more"
40113
+ },
40114
+ outMs: {
40115
+ ok: isDuration,
40116
+ hint: "the milliseconds of the fade-out, 0 or more"
40117
+ }
40118
+ });
40119
+ /** The fields of a loader. */
40120
+ const LOADER_RULES = Object.freeze({
40121
+ afterMs: {
40122
+ ok: isDuration,
40123
+ hint: "the milliseconds the loader waits for, 0 or more"
40124
+ },
40125
+ texture: {
40126
+ ok: isName,
40127
+ hint: "an asset key such as \"ui.loader.ring\""
40128
+ },
40129
+ projection: {
40130
+ ok: isName,
40131
+ hint: "the name of a registered projection such as \"loading\""
40132
+ }
40133
+ });
40134
+ /**
40135
+ * Builds the error of a field of the wrong shape.
40136
+ *
40137
+ * @param where - Where the part was written.
40138
+ * @param value - What the field holds.
40139
+ * @param hint - What to give instead.
40140
+ * @returns The error, in the engine's two-line format.
40141
+ * @example
40142
+ * ```ts
40143
+ * wrong("scenes.leave.cover.alpha", 2, "a number from 0 to 1").message;
40144
+ * // '[game] scenes.leave.cover.alpha is 2.\n Give a number from 0 to 1.'
40145
+ * ```
40146
+ */
40147
+ function wrong(where, value, hint) {
40148
+ return /* @__PURE__ */ new Error(`[game] ${where} is ${String(value)}.\n Give ${hint}.`);
40149
+ }
40150
+ /**
40151
+ * Checks one part of a `leave`: nothing, `false`, or an object whose every field it knows has the right shape.
40152
+ *
40153
+ * @param where - Where the part was written, `scenes.leave.cover`.
40154
+ * @param part - What the part holds.
40155
+ * @param rules - The fields of the part.
40156
+ * @throws {Error} For a part that is neither `false` nor an object, and for the first field of the wrong shape.
40157
+ */
40158
+ function checkPart(where, part, rules) {
40159
+ if (part === void 0 || part === false) return;
40160
+ if (!isRecord$2(part)) throw wrong(where, part, "an object of its fields, or false");
40161
+ for (const [field, rule] of Object.entries(rules)) {
40162
+ const value = part[field];
40163
+ if (value !== void 0 && !rule.ok(value)) throw wrong(`${where}.${field}`, value, rule.hint);
40164
+ }
40165
+ }
40166
+ /**
40167
+ * Checks a `leave` a game wrote: nothing, `false`, or an object whose `cover` and `loader` are each nothing, `false`
40168
+ * or an object of the right fields: a colour, an alpha from 0 to 1, durations of 0 or more, and names that are not
40169
+ * empty.
40170
+ *
40171
+ * @param where - Where it was written, for the message: `scenes.leave`, or `Scene "board": leave`.
40172
+ * @param leave - What was written.
40173
+ * @throws {Error} For the first part or field of the wrong shape.
40174
+ * @example
40175
+ * ```ts
40176
+ * checkLeave("scenes.leave", { cover: { alpha: 2 } });
40177
+ * // throws '[game] scenes.leave.cover.alpha is 2.\n Give the alpha the veil fades in to, a number from 0 to 1.'
40178
+ * ```
40179
+ */
40180
+ function checkLeave(where, leave) {
40181
+ if (leave === void 0 || leave === false) return;
40182
+ if (!isRecord$2(leave)) throw wrong(where, leave, "{ cover, loader } or false");
40183
+ checkPart(`${where}.cover`, leave.cover, COVER_RULES);
40184
+ checkPart(`${where}.loader`, leave.loader, LOADER_RULES);
40185
+ }
40186
+ //#endregion
39104
40187
  //#region src/plugins/scenes/switch.ts
39105
40188
  /**
39106
40189
  * A promise that resolves when the node was aborted. Already aborted resolves at once.
@@ -39298,11 +40381,31 @@ async function standUp(ctx, scene, named, run) {
39298
40381
  swapIn(ctx, scene, named);
39299
40382
  }
39300
40383
  /**
39301
- * Switches to another scene: it reads what the scene names from the committed state, awaits all
39302
- * of it and only then builds the scene. In live mode it waits between the two for the textures of
39303
- * those bundles to reach the GPU; a fast walk does not, so tests and restores keep their pace. An
39304
- * aborted node, or a plugin stopped during the wait, returns without touching anything. A live
39305
- * switch with a drawing renderer resolves at the commit of the scene built aside, frames later.
40384
+ * Awaits every bundle the scene names and only then builds the scene. In live mode it waits between
40385
+ * the two for the textures of those bundles to reach the GPU; a fast walk does not, so tests and
40386
+ * restores keep their pace. An aborted node, or a plugin stopped during the wait, returns without
40387
+ * touching anything. A live switch with a drawing renderer resolves at the commit of the scene
40388
+ * built aside, frames later.
40389
+ *
40390
+ * @param ctx - Domain context of the plugin.
40391
+ * @param scene - The scene to mount.
40392
+ * @param named - The bundles it names, read from the committed state.
40393
+ * @param run - The runner's mode and signal.
40394
+ * @returns A promise that resolves once the scene stands, or the node was aborted.
40395
+ * @throws {Error} Whatever a load, the mount or the build threw.
40396
+ */
40397
+ async function loadAndStand(ctx, scene, named, run) {
40398
+ await untilAborted(ctx, named, run.signal);
40399
+ if (run.mode === "live" && !run.signal.aborted) await untilWarmed(ctx, named, run.signal);
40400
+ const stopped = ctx.state.teardown === void 0;
40401
+ if (run.signal.aborted || stopped) return;
40402
+ await standUp(ctx, scene, named, run);
40403
+ }
40404
+ /**
40405
+ * Switches to another scene: it reads what the scene names from the committed state, shows the
40406
+ * veil and the loader of the scene being left at once (a cold first visit reacts in the next
40407
+ * frame), then awaits the bundles and builds the scene (`loadAndStand`). The look lands when the
40408
+ * scene stands, and is dropped when the switch ends without it or throws.
39306
40409
  *
39307
40410
  * @param ctx - Domain context of the plugin.
39308
40411
  * @param target - Id of the scene to mount.
@@ -39314,11 +40417,15 @@ async function switchTo(ctx, target, run) {
39314
40417
  const scene = ctx.state.scenes.get(target);
39315
40418
  if (scene === void 0) throw new Error(`[game] Scene "${target}" is not registered.\n List it in the scenes of a feature.`);
39316
40419
  const named = namedBundles(scene, committedState(ctx));
39317
- await untilAborted(ctx, named, run.signal);
39318
- if (run.mode === "live" && !run.signal.aborted) await untilWarmed(ctx, named, run.signal);
39319
- const stopped = ctx.state.teardown === void 0;
39320
- if (run.signal.aborted || stopped) return;
39321
- await standUp(ctx, scene, named, run);
40420
+ let leaving;
40421
+ try {
40422
+ leaving = startLeave(ctx, scene, run);
40423
+ await loadAndStand(ctx, scene, named, run);
40424
+ } catch (error) {
40425
+ endLeave(ctx, leaving, "drop");
40426
+ throw error;
40427
+ }
40428
+ endLeave(ctx, leaving, ctx.state.current === scene.id ? "land" : "drop");
39322
40429
  }
39323
40430
  /**
39324
40431
  * Reads again what the mounted scene names, at a node that keeps it: a bundle it names now and
@@ -39432,15 +40539,17 @@ function readScenes(ctx) {
39432
40539
  }
39433
40540
  }
39434
40541
  /**
39435
- * Refuses a building budget a frame cannot count down, in `onInit`, so `createApp` throws: neither
39436
- * `"auto"` nor a finite number above 0. A game written in plain JS passes anything, so the config
39437
- * is a boundary.
40542
+ * Refuses a config a switch cannot run with, in `onInit`, so `createApp` throws: a building budget
40543
+ * that is neither `"auto"` nor a finite number above 0, and a `leave` of the wrong shape. A game
40544
+ * written in plain JS passes anything, so the config is a boundary.
39438
40545
  *
39439
40546
  * @param config - The resolved config of the plugin.
39440
- * @throws {Error} When `buildMs` is neither `"auto"` nor a finite number above 0.
40547
+ * @throws {Error} When `buildMs` is neither `"auto"` nor a finite number above 0, and for a `leave`
40548
+ * `checkLeave` refuses.
39441
40549
  */
39442
40550
  function checkConfig(config) {
39443
40551
  const value = config.buildMs;
40552
+ checkLeave("scenes.leave", config.leave);
39444
40553
  if (value === "auto") return;
39445
40554
  if (isPositiveFinite(value)) return;
39446
40555
  throw new Error(`[game] scenes.buildMs is ${String(value)}.\n Give "auto" or the milliseconds a frame may spend on the next scene, a finite number above 0 such as 4.`);
@@ -39464,7 +40573,8 @@ function startScenes(ctx) {
39464
40573
  }
39465
40574
  /**
39466
40575
  * Removes the `onEnter` callback in `onStop` and forgets every scene and the bundles the mounted
39467
- * one named. A running build loses its frame callback and its switch returns. Nothing is unmounted
40576
+ * one named. A running build loses its frame callback and its switch returns; the look of a running
40577
+ * switch loses its abort listener (the renderer drops it at its own stop). Nothing is unmounted
39468
40578
  * or discarded: a teardown context may not call another plugin, and `world.onStop` clears every
39469
40579
  * entity and the build table anyway.
39470
40580
  *
@@ -39482,6 +40592,7 @@ function stopScenes(teardown) {
39482
40592
  state.teardown = void 0;
39483
40593
  dropBuild(state.build);
39484
40594
  delete state.build;
40595
+ forgetLeave(state);
39485
40596
  state.scenes.clear();
39486
40597
  state.current = void 0;
39487
40598
  state.pending = void 0;
@@ -39550,6 +40661,9 @@ function createScenesState() {
39550
40661
  *
39551
40662
  * // A game gives the next scene a fixed 3 ms of each frame (two thirds of it by default) while the old one stays on the screen.
39552
40663
  * createApp({ plugins: [...screen, boardFeature], pluginConfigs: { scenes: { buildMs: 3 } } });
40664
+ *
40665
+ * // No veil and no loader over a scene being left, on any switch.
40666
+ * createApp({ plugins: [...screen, boardFeature], pluginConfigs: { scenes: { leave: false } } });
39553
40667
  * ```
39554
40668
  */
39555
40669
  const scenesPlugin = /*#__PURE__*/ createPlugin$1("scenes", {
@@ -39561,7 +40675,18 @@ const scenesPlugin = /*#__PURE__*/ createPlugin$1("scenes", {
39561
40675
  modelPlugin,
39562
40676
  rendererPlugin
39563
40677
  ],
39564
- config: { buildMs: "auto" },
40678
+ config: {
40679
+ buildMs: "auto",
40680
+ leave: {
40681
+ cover: {
40682
+ color: 0,
40683
+ alpha: .45,
40684
+ inMs: 150,
40685
+ outMs: 150
40686
+ },
40687
+ loader: { afterMs: 400 }
40688
+ }
40689
+ },
39565
40690
  events: (register) => register.map({ "scenes:changed": "The mounted scene changed" }),
39566
40691
  createState: createScenesState,
39567
40692
  api: createScenesApi,
@@ -64680,14 +65805,15 @@ function namesOf(id, layers, projections) {
64680
65805
  * layer names come from the keys of `layers` and that `ui`, so a projection whose `layer` or
64681
65806
  * `lift` is not among them does not compile — and throws here for a caller without types.
64682
65807
  * `bundles` names bundles that depend on the committed state: the scene is mounted once those
64683
- * are in too.
65808
+ * are in too. `leave` is what a live switch shows over the scene when it is left, merged over the
65809
+ * config's: `false` shows nothing.
64684
65810
  *
64685
65811
  * @param id - Id of the scene. A node names it with `defineNode({ scene: id })`.
64686
- * @param scene - The bundle, the optional bundles of state, the layers, the projections and the
64687
- * optional music key.
65812
+ * @param scene - The bundle, the optional bundles of state, the layers, the projections, the
65813
+ * optional music key and the optional `leave`.
64688
65814
  * @returns The scene as frozen data, ready for the `scenes` key of a feature.
64689
65815
  * @throws {Error} For an integer-like layer name, for a `layer` or `lift` that is not declared, for two HUD layers,
64690
- * an `above` other than `"overlays"`, and a `ui` declared after the HUD layer.
65816
+ * an `above` other than `"overlays"`, a `ui` declared after the HUD layer, and a `leave` of the wrong shape.
64691
65817
  * @example
64692
65818
  * ```ts
64693
65819
  * // features/board/view/scene.ts of a game.
@@ -64712,18 +65838,28 @@ function namesOf(id, layers, projections) {
64712
65838
  * projections: [wardRoom, wardGlows]
64713
65839
  * });
64714
65840
  * wardScene.bundles?.({ player: {}, session: { episode: "ep02" } }); // ["ep02"]
65841
+ *
65842
+ * // Leaving the board dims it in navy and turns the game's ring texture once the switch took 400 ms.
65843
+ * defineScene("board", {
65844
+ * bundle: "board",
65845
+ * layers: { cells: {} },
65846
+ * projections: [],
65847
+ * leave: { cover: { color: 0x0b1020, alpha: 0.6 }, loader: { texture: "ui.loader.ring" } }
65848
+ * }).leave; // { cover: { color: 0x0b1020, alpha: 0.6 }, loader: { texture: "ui.loader.ring" } }
64715
65849
  * ```
64716
65850
  */
64717
65851
  function defineScene(id, scene) {
64718
65852
  const layers = layersOf(id, scene.layers);
64719
65853
  const projections = scene.projections;
65854
+ checkLeave(`Scene "${id}": leave`, scene.leave);
64720
65855
  return Object.freeze({
64721
65856
  id,
64722
65857
  bundle: scene.bundle,
64723
65858
  music: scene.music,
64724
65859
  layers: Object.freeze(layers),
64725
65860
  projections: Object.freeze(namesOf(id, layers, projections)),
64726
- ...scene.bundles === void 0 ? {} : { bundles: scene.bundles }
65861
+ ...scene.bundles === void 0 ? {} : { bundles: scene.bundles },
65862
+ ...scene.leave === void 0 ? {} : { leave: scene.leave }
64727
65863
  });
64728
65864
  }
64729
65865
  /**
@@ -65156,6 +66292,7 @@ function cubicBezier(x1, y1, x2, y2) {
65156
66292
  * | assets | baseUrl | undefined |
65157
66293
  * | assets | io | undefined, the browser fetch and the renderer textures |
65158
66294
  * | scenes | buildMs | "auto", two thirds of a frame ((1000 / time.rate().fps) * 2 / 3, 11.1 ms at 60 Hz), read at each build start; a number fixes it |
66295
+ * | scenes | leave | { cover: { color: 0x000000, alpha: 0.45, inMs: 150, outMs: 150 }, loader: { afterMs: 400 } }, what a live switch shows over the scene being left: a veil from its start and a loader (the engine's ring, a `texture` or a `projection`) once it took `afterMs`; a scene's `leave` is merged over it field by field, a field left out keeps this default, `false` turns a part or the whole off |
65159
66296
  * | anim | maxTracks | 2000 |
65160
66297
  * | anim | reducedMotion | false |
65161
66298
  * | i18n | locale | "en" |
@@ -65300,4 +66437,4 @@ const screen = [
65300
66437
  uiPlugin
65301
66438
  ];
65302
66439
  //#endregion
65303
- export { sfx as $, flowPlugin as $t, types_exports$10 as A, Glow as At, defineMotion as B, Animation as Bt, types_exports$3 as C, messageDuration as Ct, types_exports$7 as D, Blur as Dt, types_exports$6 as E, Alpha as Et, types_exports$15 as F, audioPlugin as Ft, external as G, MONITOR_SECTIONS as Gt, platformPlugin as H, rendererPlugin as Ht, types_exports$16 as I, scenesPlugin as It, mark as J, PERF_LOG_VERSION as Jt, frames as K, PERF_CAUSES as Kt, types_exports$17 as L, assetsPlugin as Lt, types_exports$12 as M, Outline as Mt, types_exports$13 as N, defineFilter as Nt, types_exports$8 as O, ColorMatrix as Ot, types_exports$14 as P, debugPlugin as Pt, set as Q, worldPlugin as Qt, uiPlugin as R, animPlugin as Rt, types_exports$2 as S, messageArgument as St, types_exports$5 as T, Emitter as Tt, HAPTIC_KINDS as U, PASS_KINDS as Ut, textPlugin as V, Frames as Vt, defineAnimation as W, frameAt as Wt, play$1 as X, SHADER_WHATS as Xt, parallel as Y, PROBLEM_RULES as Yt, sequence as Z, DEMAND_REASONS as Zt, defineBundles as _, Tappable as _t, cubicBezier as a, modelPlugin as an, use as at, types_exports as b, Traced as bt, hint as c, timePlugin as cn, Draggable as ct, defineScene as d, Hovered as dt, refuseStart as en, spawn as et, tr as f, Pointer as ft, music as g, Swipeable as gt, defineEmitter as h, Pressed as ht, screen as i, type as in, tween as it, types_exports$11 as j, Noise as jt, types_exports$9 as k, Displacement as kt, schedule as l, clockPlugin as ln, DropTarget as lt, flattenFeatures as m, Pressable as mt, createPlugin as n, slot as nn, stagger as nt, defineEffect as o, SaveUnreadableError as on, wait as ot, defineFeature as p, PointerOver as pt, haptic as q, PERF_GAP_BUCKETS as qt, defineGame as r, to as rn, stopSfx as rt, guide as s, lifecyclePlugin as sn, inputPlugin as st, createApp as t, exit as tn, spawned as tt, projection as u, Held as ut, load as v, Touchable as vt, types_exports$4 as w, effectsPlugin as wt, types_exports$1 as x, i18nPlugin as xt, ready as y, Traceable as yt, resolve as z, AnimPlayer as zt };
66440
+ export { sfx as $, flowPlugin as $t, types_exports$10 as A, Glow as At, defineMotion as B, Animation as Bt, types_exports$3 as C, messageDuration as Ct, types_exports$7 as D, Blur as Dt, types_exports$6 as E, Alpha as Et, types_exports$15 as F, audioPlugin as Ft, external as G, MONITOR_SECTIONS as Gt, platformPlugin as H, rendererPlugin as Ht, types_exports$16 as I, scenesPlugin as It, mark as J, PERF_LOG_VERSION as Jt, frames as K, PERF_CAUSES as Kt, types_exports$17 as L, assetsPlugin as Lt, types_exports$12 as M, Outline as Mt, types_exports$13 as N, defineFilter as Nt, types_exports$8 as O, ColorMatrix as Ot, types_exports$14 as P, debugPlugin as Pt, set as Q, worldPlugin as Qt, uiPlugin as R, animPlugin as Rt, types_exports$2 as S, messageArgument as St, types_exports$5 as T, Emitter as Tt, HAPTIC_KINDS as U, PASS_KINDS as Ut, textPlugin as V, Frames as Vt, defineAnimation as W, frameAt as Wt, play$1 as X, SHADER_WHATS as Xt, parallel as Y, PROBLEM_RULES as Yt, sequence as Z, DEMAND_REASONS as Zt, defineBundles as _, Tappable as _t, cubicBezier as a, type as an, use as at, types_exports as b, Traced as bt, hint as c, lifecyclePlugin as cn, Draggable as ct, defineScene as d, Hovered as dt, refuseStart as en, spawn as et, tr as f, Pointer as ft, music as g, Swipeable as gt, defineEmitter as h, Pressed as ht, screen as i, to as in, tween as it, types_exports$11 as j, Noise as jt, types_exports$9 as k, Displacement as kt, schedule as l, timePlugin as ln, DropTarget as lt, flattenFeatures as m, Pressable as mt, createPlugin as n, exit as nn, stagger as nt, defineEffect as o, modelPlugin as on, wait as ot, defineFeature as p, PointerOver as pt, haptic as q, PERF_GAP_BUCKETS as qt, defineGame as r, slot as rn, stopSfx as rt, guide as s, SaveUnreadableError as sn, inputPlugin as st, createApp as t, contribution as tn, spawned as tt, projection as u, clockPlugin as un, Held as ut, load as v, Touchable as vt, types_exports$4 as w, effectsPlugin as wt, types_exports$1 as x, i18nPlugin as xt, ready as y, Traceable as yt, resolve as z, AnimPlayer as zt };