incanto 0.68.0 → 0.70.0

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 (153) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/_behaviors-loader.mjs +22 -0
  3. package/bin/_read-json.mjs +28 -0
  4. package/bin/incanto-assets.mjs +19 -5
  5. package/bin/incanto-check.mjs +92 -15
  6. package/bin/incanto-editor.mjs +128 -5
  7. package/bin/incanto-env.mjs +3 -2
  8. package/bin/incanto-feel.mjs +24 -13
  9. package/bin/incanto-frame.mjs +8 -1
  10. package/bin/incanto-multiplay.mjs +11 -9
  11. package/bin/incanto-new.mjs +128 -5
  12. package/bin/incanto-play.mjs +158 -16
  13. package/bin/incanto-playtest.mjs +66 -23
  14. package/bin/incanto-serve.mjs +160 -0
  15. package/bin/incanto-skills.mjs +14 -2
  16. package/bin/incanto-verify.mjs +165 -44
  17. package/bin/incanto.mjs +4 -2
  18. package/dist/2d.d.ts +285 -36
  19. package/dist/2d.js +4 -4
  20. package/dist/3d.d.ts +158 -10
  21. package/dist/3d.js +8 -8
  22. package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
  23. package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-DaMxqfNE.d.ts} +33 -14
  24. package/dist/{behavior-DoFPYrgo.d.ts → behavior-DZExDn9o.d.ts} +809 -44
  25. package/dist/{create-game-IZIydDwI.js → create-game-Bwvh6q8A.js} +148 -61
  26. package/dist/{create-game-DbWtVTxD.js → create-game-C7ffQWW7.js} +103 -49
  27. package/dist/debug.d.ts +1 -1
  28. package/dist/debug.js +2 -3
  29. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  30. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  31. package/dist/editor.js +1066 -864
  32. package/dist/env.d.ts +1 -1
  33. package/dist/env.js +5 -3
  34. package/dist/{environment-presets-CybQXNqS.js → environment-presets-D6Q5BxeE.js} +299 -46
  35. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  36. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  37. package/dist/{gameplay-DM1eu_cV.js → gameplay-BfHkuzVb.js} +825 -221
  38. package/dist/gameplay.d.ts +187 -7
  39. package/dist/gameplay.js +1 -1
  40. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  41. package/dist/index.d.ts +156 -13
  42. package/dist/index.js +10 -12
  43. package/dist/json-CfTjpvW8.js +67 -0
  44. package/dist/{loader-CcB533FR.d.ts → loader-Cff09LMm.d.ts} +2 -2
  45. package/dist/net.d.ts +27 -3
  46. package/dist/net.js +2 -2
  47. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  48. package/dist/{physics-2d-B7Y6dPZO.js → physics-2d-CE0Qvy3V.js} +136 -11
  49. package/dist/{physics-3d-bG3n70Ky.js → physics-3d-CpH-2gn5.js} +104 -23
  50. package/dist/{teardown-D2NEmxPB.js → picking-CQJ_PJKh.js} +106 -14
  51. package/dist/react.d.ts +2 -2
  52. package/dist/react.js +2 -2
  53. package/dist/{register-3ta-2Xig.js → register-6DYnKZcy.js} +652 -831
  54. package/dist/{register-ibCjm-wH.js → register-Bkk0wSDB.js} +348 -30
  55. package/dist/{replay-CYvhVHHN.js → replay-DjAkAzMq.js} +224 -14
  56. package/dist/{replay-Dvn8aeBd.d.ts → replay-Dmw-PKQu.d.ts} +20 -3
  57. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  58. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  59. package/dist/{loader-BC4PNtJX.js → save-slots-BXVg148r.js} +4558 -2294
  60. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  61. package/dist/{split-screen-DhrSzZIB.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
  62. package/dist/{split-screen-CYwDkbLF.js → split-screen-PL78oVXP.js} +159 -26
  63. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  64. package/dist/{src-D7RIqXYF.js → src-Cxfiv1Hg.js} +2 -17
  65. package/dist/test-iHYVUcDK.js +4036 -0
  66. package/dist/test.d.ts +542 -30
  67. package/dist/test.js +3 -3
  68. package/dist/touch-BnCyPA0G.js +519 -0
  69. package/dist/vite.d.ts +54 -3
  70. package/dist/vite.js +349 -17
  71. package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-C8aDbGmR.js} +56 -1
  72. package/editor/assets/agent8-D0MS174y.js +1 -0
  73. package/editor/assets/{debug-BBhKuBNV.js → debug-BnXkKuYu.js} +2 -2
  74. package/editor/assets/index-CIu3uc3l.js +11046 -0
  75. package/editor/index.html +1 -1
  76. package/package.json +7 -16
  77. package/schemas/scene.schema.json +30 -3
  78. package/skills/incanto-3d-character.md +14 -1
  79. package/skills/incanto-3d-models.md +12 -0
  80. package/skills/incanto-assets.md +25 -3
  81. package/skills/incanto-audio.md +27 -5
  82. package/skills/incanto-behaviors-and-scripts.md +83 -6
  83. package/skills/incanto-building-2d-games.md +106 -8
  84. package/skills/incanto-building-3d-games.md +118 -6
  85. package/skills/incanto-editor.md +46 -7
  86. package/skills/incanto-environment.md +19 -1
  87. package/skills/incanto-game-feel.md +70 -0
  88. package/skills/incanto-gameplay-behaviors.md +121 -14
  89. package/skills/incanto-hud.md +128 -7
  90. package/skills/incanto-localization.md +13 -5
  91. package/skills/incanto-multiplayer.md +83 -3
  92. package/skills/incanto-node-reference.md +222 -58
  93. package/skills/incanto-performance.md +52 -0
  94. package/skills/incanto-physics-and-input.md +123 -24
  95. package/skills/incanto-playtesting.md +78 -2
  96. package/skills/incanto-save-slots.md +188 -6
  97. package/skills/incanto-scene-json-authoring.md +69 -12
  98. package/skills/incanto-verifying-your-game.md +292 -12
  99. package/skills/incanto-web-integration.md +28 -0
  100. package/skills/incanto-your-first-game.md +5 -2
  101. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  102. package/templates-app/beacon-isle-3d/package.json +2 -2
  103. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  104. package/templates-app/beacon-isle-3d/src/game.scene.json +103 -378
  105. package/templates-app/beacon-isle-3d/src/main.ts +24 -4
  106. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  107. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  108. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  109. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  110. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  111. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  112. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  113. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  114. package/templates-app/molehill-2d/index.html +73 -0
  115. package/templates-app/molehill-2d/package.json +23 -0
  116. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  117. package/templates-app/molehill-2d/src/game.scene.json +1255 -0
  118. package/templates-app/molehill-2d/src/main.ts +41 -0
  119. package/templates-app/molehill-2d/tsconfig.json +13 -0
  120. package/templates-app/molehill-2d/verify.ts +247 -0
  121. package/templates-app/molehill-2d/vite.config.ts +12 -0
  122. package/templates-app/platformer-2d/index.html +0 -23
  123. package/templates-app/platformer-2d/package.json +2 -2
  124. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  125. package/templates-app/platformer-2d/src/game.scene.json +143 -625
  126. package/templates-app/platformer-2d/src/main.ts +35 -13
  127. package/templates-app/platformer-2d/tsconfig.json +1 -1
  128. package/templates-app/star-survivor/package.json +2 -2
  129. package/templates-app/star-survivor/src/game.scene.json +41 -195
  130. package/templates-app/star-survivor/src/main.ts +28 -7
  131. package/templates-app/star-survivor/tsconfig.json +1 -1
  132. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  133. package/templates-app/tps-3d/package.json +2 -2
  134. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  135. package/templates-app/tps-3d/src/game.scene.json +78 -217
  136. package/templates-app/tps-3d/src/main.ts +39 -17
  137. package/templates-app/tps-3d/tsconfig.json +1 -1
  138. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  139. package/templates-app/village-quest-3d/package.json +2 -2
  140. package/templates-app/village-quest-3d/src/behaviors.ts +21 -0
  141. package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
  142. package/templates-app/village-quest-3d/src/main.ts +24 -4
  143. package/templates-app/village-quest-3d/src/village.scene.json +199 -838
  144. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  145. package/templates-app/village-quest-3d/verify.ts +14 -1
  146. package/dist/duplicate-IWIqk0HJ.js +0 -22
  147. package/dist/json-CwwhxQgb.js +0 -36
  148. package/dist/registry-CF70EArN.js +0 -212
  149. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  150. package/dist/test-8hoHeRmo.js +0 -2340
  151. package/dist/touch-DEAmqGdf.js +0 -225
  152. package/editor/assets/agent8-BrrHOjMJ.js +0 -1
  153. package/editor/assets/index-eVd0BToA.js +0 -10958
@@ -1,9 +1,8 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { C as ORDER_GROUP_BASE, E as InputMap, O as Node, S as createSaveStore, T as resolveOrderGroups, V as Signal, b as restoreBehaviors, k as diagnose, y as captureBehaviors } from "./loader-BC4PNtJX.js";
1
+ import { o as __exportAll } from "./json-CfTjpvW8.js";
2
+ import { B as TAPPABLE, G as requireText, H as UiBar, K as Node, L as FITS, N as resolveOrderGroups, P as InputMap, R as HudLayer, U as UiText, V as UiBanner, a as restoreBehaviors, i as captureBehaviors, j as ORDER_GROUP_BASE, k as createSaveStore, ot as Signal, q as diagnose, t as ENGINE_STATE_KEY, z as HudWidgetBase } from "./save-slots-BXVg148r.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { t as Rng } from "./rng-DP-SR7eg.js";
5
- import { l as registerNode } from "./registry-CF70EArN.js";
6
- import { r as capturePointer } from "./touch-DEAmqGdf.js";
4
+ import { t as Rng } from "./rng-CDOMybym.js";
5
+ import { p as registerNode } from "./touch-BnCyPA0G.js";
7
6
  //#region src/core/audio/buses.ts
8
7
  const AUDIO_LOG_LIMIT = 200;
9
8
  var AudioBuses = class {
@@ -22,15 +21,33 @@ var AudioBuses = class {
22
21
  * Bounded (200) — a game running for an hour must not grow a log.
23
22
  */
24
23
  events = [];
24
+ /**
25
+ * EVERY node that has sounded, ever — a set of paths, not a window.
26
+ *
27
+ * `recent()` is bounded at 200, which is right for a log and wrong for the
28
+ * question "is this AudioPlayer wired?". A game with one sound firing about
29
+ * three times a second overflows the window inside a minute, so a 60-second
30
+ * playtest read the tail and reported correctly-wired feedback as never
31
+ * fired. This grows with the SCENE, not with the run — which is true only
32
+ * because it records the AUTHORED path: a clone's own path is new every
33
+ * spawn, and recording those grew the set by one entry per enemy, forever,
34
+ * in every shipped game. See `Node.wiringPath`.
35
+ */
36
+ sourcePaths = /* @__PURE__ */ new Set();
25
37
  /** @internal Called by the players; games read `recent()`. */
26
38
  record(event) {
27
39
  this.events.push(event);
28
40
  if (this.events.length > AUDIO_LOG_LIMIT) this.events.shift();
41
+ this.sourcePaths.add(event.wiredAs ?? event.from);
29
42
  }
30
43
  /** The last `limit` sounds, oldest first. */
31
44
  recent(limit = AUDIO_LOG_LIMIT) {
32
45
  return this.events.slice(Math.max(0, this.events.length - limit));
33
46
  }
47
+ /** Every node path that has sounded since the last `clearLog()`. */
48
+ sources() {
49
+ return [...this.sourcePaths];
50
+ }
34
51
  /** How many times a preset/clip/track sounded — the assertion you want. */
35
52
  countOf(name) {
36
53
  return this.events.reduce((n, e) => e.name === name ? n + 1 : n, 0);
@@ -38,11 +55,13 @@ var AudioBuses = class {
38
55
  /** Forget what has sounded so far (between scenes, or between assertions). */
39
56
  clearLog() {
40
57
  this.events.length = 0;
58
+ this.sourcePaths.clear();
41
59
  }
42
60
  _master = 1;
43
61
  _sfx = 1;
44
62
  _music = 1;
45
63
  _muted = false;
64
+ _suspended = false;
46
65
  get master() {
47
66
  return this._master;
48
67
  }
@@ -69,9 +88,30 @@ var AudioBuses = class {
69
88
  this._muted = v;
70
89
  this.changed.emit();
71
90
  }
91
+ /**
92
+ * Silence the game WITHOUT it counting as the player muting it.
93
+ *
94
+ * `pause-when-hidden` set `muted = true` when the tab went away, which fires
95
+ * `changed`, which `Settings.bindAudio` persists. Close the tab while it is
96
+ * hidden and `muted: true` is on disk: EVERY later session of that game boots
97
+ * silent, and no shipped starter has an in-game way back.
98
+ *
99
+ * A suspension is the engine's, not the player's. It never touches `muted`,
100
+ * so nothing persists it and unsuspending cannot turn the sound back on for
101
+ * someone who turned it off.
102
+ */
103
+ suspend(on) {
104
+ if (on === this._suspended) return;
105
+ this._suspended = on;
106
+ this.changed.emit();
107
+ }
108
+ /** Is the engine holding the sound off (hidden tab), as against the player? */
109
+ get suspended() {
110
+ return this._suspended;
111
+ }
72
112
  /** Final gain for a sound on `bus` with its own `sourceVolume` (all clamped). */
73
113
  effectiveVolume(bus, sourceVolume) {
74
- if (this._muted) return 0;
114
+ if (this._muted || this._suspended) return 0;
75
115
  const busGain = bus === "music" ? this._music : this._sfx;
76
116
  return this._master * busGain * clamp01$3(sourceVolume);
77
117
  }
@@ -241,16 +281,36 @@ var WebAudioMusicBackend = class {
241
281
  };
242
282
  //#endregion
243
283
  //#region src/core/audio/music-manager.ts
244
- /** Mints silent tracks so the state machine runs with no audio backend. */
284
+ /**
285
+ * Mints silent tracks so the state machine runs with no audio backend.
286
+ *
287
+ * Including its CLOCK. `playhead: () => null` meant that headless — the verify
288
+ * VM, the only place a game is checked automatically — "where is the song" had
289
+ * no answer, so a game charted to a soundtrack could not be tested at all. The
290
+ * silent track carries the same elapsed-seconds counter the fades already run
291
+ * on. It has no duration to wrap at, so it counts up for as long as it plays.
292
+ */
245
293
  const SILENT_BACKEND = {
246
- createTrack: (src) => ({
247
- src,
248
- setGain() {},
249
- setLoop() {},
250
- play() {},
251
- stop() {},
252
- playhead: () => null
253
- }),
294
+ createTrack: (src) => {
295
+ let playing = false;
296
+ let at = 0;
297
+ return {
298
+ src,
299
+ setGain() {},
300
+ setLoop() {},
301
+ play() {
302
+ playing = true;
303
+ },
304
+ stop() {
305
+ playing = false;
306
+ at = 0;
307
+ },
308
+ tick(dt) {
309
+ if (playing && Number.isFinite(dt) && dt > 0) at += dt;
310
+ },
311
+ playhead: () => playing ? at : null
312
+ };
313
+ },
254
314
  unlock() {}
255
315
  };
256
316
  /**
@@ -373,13 +433,6 @@ var MusicManager = class {
373
433
  * it's a fade-in. Crossfading to the already-current src is a no-op.
374
434
  */
375
435
  crossfadeTo(src, seconds = 2) {
376
- this.buses.record({
377
- kind: "music",
378
- name: src,
379
- from: "engine.music",
380
- bus: this._bus,
381
- at: 0
382
- });
383
436
  const backend = this.ensure();
384
437
  if (this.active && this.active.track.src === src && this.active.phase !== "fadeOut") return;
385
438
  const secs = Math.max(0, seconds);
@@ -387,6 +440,13 @@ var MusicManager = class {
387
440
  this.play(src, { fadeIn: secs });
388
441
  return;
389
442
  }
443
+ this.buses.record({
444
+ kind: "music",
445
+ name: src,
446
+ from: "engine.music",
447
+ bus: this._bus,
448
+ at: 0
449
+ });
390
450
  this.killOutgoing();
391
451
  this.outgoing = {
392
452
  ...this.active,
@@ -446,6 +506,8 @@ var MusicManager = class {
446
506
  }
447
507
  /** Advance fades by `dt` seconds and (re)apply bus×fade gains. Frame-driven. */
448
508
  tick(dt) {
509
+ this.active?.track.tick?.(dt);
510
+ this.outgoing?.track.tick?.(dt);
449
511
  if (this.active && (this.active.phase === "fadeIn" || this.active.phase === "crossIn")) {
450
512
  this.active.t += dt;
451
513
  if (this.active.t >= this.active.duration) this.active.phase = "steady";
@@ -1063,11 +1125,27 @@ var SfxEngine = class {
1063
1125
  * The AUDIO clock, in seconds — the one a sound is actually scheduled
1064
1126
  * against, and the only clock that does not drift relative to what you hear.
1065
1127
  *
1066
- * `0` headless and before the first sound, since there is no context yet.
1067
1128
  * Schedule with a lead: `sfx.play(params, 1, { when: sfx.now + 0.08 })`.
1129
+ *
1130
+ * With no AudioContext — headless, and before the first sound — it is the
1131
+ * engine's own real elapsed time instead of a frozen `0`. A constant made the
1132
+ * documented lookahead scheduler queue the notes inside the first 80 ms and
1133
+ * then nothing ever again: measured on a 21-beat metronome in the verify VM,
1134
+ * **1 of 21 beats**, with no error and no warning. The silent clock runs so
1135
+ * the schedule is reachable; nothing sounds either way.
1068
1136
  */
1069
1137
  get now() {
1070
- return this.ctx?.currentTime ?? 0;
1138
+ return this.ctx?.currentTime ?? this.silentNow;
1139
+ }
1140
+ /** The clock `now` reports when there is no context to ask. */
1141
+ silentNow = 0;
1142
+ /**
1143
+ * @internal Advance the silent clock — the engine calls this every frame with
1144
+ * the REAL dt, beside `music.tick`, because the audio does not slow down when
1145
+ * the game does.
1146
+ */
1147
+ _tick(dt) {
1148
+ if (Number.isFinite(dt) && dt > 0) this.silentNow += dt;
1071
1149
  }
1072
1150
  /**
1073
1151
  * Play a preset at `gain` (0..1, already bus-multiplied by the caller). Each
@@ -1257,728 +1335,28 @@ function translateOn(node, value) {
1257
1335
  return locale ? locale.resolve(value) : value;
1258
1336
  }
1259
1337
  //#endregion
1260
- //#region src/core/nodes/hud.ts
1261
- const ANCHOR_CSS = {
1262
- topLeft: "left:16px;top:16px;align-items:flex-start;",
1263
- top: "left:50%;top:16px;transform:translateX(-50%);align-items:center;",
1264
- topRight: "right:16px;top:16px;align-items:flex-end;",
1265
- left: "left:16px;top:50%;transform:translateY(-50%);align-items:flex-start;",
1266
- center: "left:50%;top:50%;transform:translate(-50%,-50%);align-items:center;",
1267
- right: "right:16px;top:50%;transform:translateY(-50%);align-items:flex-end;",
1268
- bottomLeft: "left:16px;bottom:16px;align-items:flex-start;",
1269
- bottom: "left:50%;bottom:16px;transform:translateX(-50%);align-items:center;",
1270
- bottomRight: "right:16px;bottom:16px;align-items:flex-end;"
1271
- };
1272
- const FONT = "600 14px system-ui, -apple-system, 'Segoe UI', sans-serif";
1273
- function hasDom() {
1274
- return typeof document !== "undefined";
1275
- }
1276
- /**
1277
- * The overlay container. One per scene is plenty; widgets mount into its
1278
- * anchor slots. `zIndex` lifts it above game canvases; the layer never eats
1279
- * pointer events (widgets that need clicks opt in individually).
1280
- */
1281
- var HudLayer = class extends Node {
1282
- static typeName = "HudLayer";
1283
- static props = {
1284
- zIndex: { default: 100 },
1285
- visible: { default: true },
1286
- /**
1287
- * Arrow keys / d-pad move focus between the focusable widgets under this
1288
- * layer, Enter / A activates. OFF by default — a game whose HUD has a
1289
- * button must not lose its arrow keys the moment one exists.
1290
- */
1291
- focusNavigation: { default: false }
1292
- };
1293
- zIndex = 100;
1294
- visible = true;
1295
- focusNavigation = false;
1296
- /** @internal root overlay element (null headless). */
1297
- _element = null;
1298
- slots = /* @__PURE__ */ new Map();
1299
- onEnterTree() {
1300
- if (!hasDom()) return;
1301
- const el = document.createElement("div");
1302
- el.dataset.incantoHud = this.name;
1303
- el.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${this.zIndex};font:${FONT};`;
1304
- this._element = el;
1305
- this._mount();
1306
- }
1307
- /**
1308
- * Put the overlay where the page's owner says UI goes: `engine.uiHost`, or
1309
- * the window when nobody claims it (a game — a HUD covers the screen).
1310
- *
1311
- * Re-checked every frame because a scene's tree is BUILT before it is handed
1312
- * to an engine: at onEnterTree there is no engine to ask yet, so the first
1313
- * update is where the answer actually arrives. appendChild moves the element
1314
- * if it already landed somewhere else, so the HUD follows the host across a
1315
- * scene swap into a differently-hosted engine (game → editor) instead of
1316
- * being stranded on document.body over the inspector.
1317
- */
1318
- _mount() {
1319
- const el = this._element;
1320
- if (!el) return;
1321
- const host = this.tree?.engine?.uiHost ?? document.body;
1322
- if (el.parentElement === host) return;
1323
- el.style.position = host === document.body ? "fixed" : "absolute";
1324
- host.appendChild(el);
1325
- }
1326
- onExitTree() {
1327
- this._element?.remove();
1328
- this._element = null;
1329
- this.slots.clear();
1330
- }
1331
- update() {
1332
- if (this._element) {
1333
- this._mount();
1334
- this._element.style.display = this.visible ? "" : "none";
1335
- }
1336
- if (this.focusNavigation) this.stepFocus();
1337
- }
1338
- /**
1339
- * Arrow keys / d-pad move the focus, Enter / A activates it.
1340
- *
1341
- * Off by default: a game whose HUD has a button must not lose its arrow keys
1342
- * to a menu the moment one exists. Turn it on for the screens that ARE menus
1343
- * (`"focusNavigation": true` on the pause panel's layer), and off again when
1344
- * play resumes.
1345
- */
1346
- stepFocus() {
1347
- const engine = this.tree?.engine;
1348
- if (!engine) return;
1349
- const items = this.focusables();
1350
- if (items.length === 0) return;
1351
- const input = engine.input;
1352
- const down = input.keyJustPressed("ArrowDown") || input.keyJustPressed("Pad13");
1353
- const up = input.keyJustPressed("ArrowUp") || input.keyJustPressed("Pad12");
1354
- const left = input.keyJustPressed("ArrowLeft") || input.keyJustPressed("Pad14");
1355
- const right = input.keyJustPressed("ArrowRight") || input.keyJustPressed("Pad15");
1356
- const confirm = input.keyJustPressed("Enter") || input.keyJustPressed("Pad0");
1357
- let index = items.findIndex((w) => w._focused);
1358
- if (index === -1 && (down || up || confirm)) index = 0;
1359
- else if (down) index = (index + 1) % items.length;
1360
- else if (up) index = (index - 1 + items.length) % items.length;
1361
- if (index !== -1) {
1362
- for (const w of items) {
1363
- const next = w === items[index];
1364
- if (next !== w._focused) {
1365
- w._focused = next;
1366
- w._paintFocus();
1367
- }
1368
- }
1369
- const focused = items[index];
1370
- if (focused) {
1371
- if (confirm) focused._activate(0);
1372
- else if (left) focused._activate(-1);
1373
- else if (right) focused._activate(1);
1374
- }
1375
- }
1376
- }
1377
- /**
1378
- * Focusable widgets under this layer, in tree order — **on screen only**.
1379
- *
1380
- * This filtered on each widget's OWN `visible` and recursed into hidden
1381
- * containers anyway, so arrow-keys walked into closed panels and Enter really
1382
- * pressed what it found. Measured on a title screen: 23 focus stops, 21 of
1383
- * them under an ancestor with `visible = false`, and
1384
- *
1385
- * gold before = 150 -> Enter on the hidden Shop/Row0/Buy -> gold 120,
1386
- * screen still 'title'
1387
- *
1388
- * The layer's own visibility was not a gate either. It is now: a HUD nobody
1389
- * can see has no ring, and a subtree nobody can see is not walked.
1390
- *
1391
- * PUBLIC, because a game that wants to know what its ring contains could not
1392
- * ask — and "why is focus on that" is exactly the question this answers.
1393
- */
1394
- focusables() {
1395
- const out = [];
1396
- if (!this.visible) return out;
1397
- const walk = (node) => {
1398
- if (node instanceof HudWidgetBase && !node.visible) return;
1399
- if (node instanceof HudWidgetBase && node.focusable) out.push(node);
1400
- for (const child of node.children) walk(child);
1401
- };
1402
- walk(this);
1403
- return out;
1404
- }
1405
- /** Move focus to a widget by hand — opening a menu should start somewhere. */
1406
- focus(widget) {
1407
- for (const w of this.focusables()) {
1408
- const next = w === widget;
1409
- if (next !== w._focused) {
1410
- w._focused = next;
1411
- w._paintFocus();
1412
- }
1413
- }
1414
- }
1415
- /** @internal Widgets mount into per-anchor flex columns. */
1416
- _slot(anchor) {
1417
- if (!this._element) return null;
1418
- let slot = this.slots.get(anchor);
1419
- if (!slot) {
1420
- slot = document.createElement("div");
1421
- slot.style.cssText = `position:absolute;display:flex;flex-direction:column;gap:8px;${ANCHOR_CSS[anchor] ?? ANCHOR_CSS.topLeft}`;
1422
- this._element.appendChild(slot);
1423
- this.slots.set(anchor, slot);
1424
- }
1425
- return slot;
1426
- }
1427
- };
1428
- /**
1429
- * The widget an element belongs to.
1430
- *
1431
- * A pick lands on whatever div is under the cursor — usually a CHILD of the
1432
- * widget (an icon inside a slot), so the walk upward is the point.
1433
- */
1434
- const widgetOf = /* @__PURE__ */ new WeakMap();
1435
- /** The nearest ancestor widget that actually accepts a drop. */
1436
- function dropTargetFromElement(el) {
1437
- for (let n = el; n; n = n.parentElement) {
1438
- const found = widgetOf.get(n);
1439
- if (found?.dropTarget) return found;
1440
- }
1441
- return null;
1442
- }
1443
- function widgetFromElement(el) {
1444
- for (let n = el; n; n = n.parentElement) {
1445
- const found = widgetOf.get(n);
1446
- if (found) return found;
1447
- }
1448
- return null;
1449
- }
1450
- /** The drag in flight, if any. One pointer, one drag. */
1451
- let dragging = null;
1452
- /** The drop target the dragged widget came OUT of — putting it back is a no-op. */
1453
- let draggingFrom = null;
1454
- /** Shared plumbing: mount into the parent HudLayer's anchor slot. */
1455
- var HudWidgetBase = class extends Node {
1456
- /**
1457
- * Wireable visibility: `noSave → Continue.hide`.
1458
- *
1459
- * `visible` is a prop, and a `connections[]` handler has to be a METHOD — the
1460
- * same wall that kept a score off the screen until widgets grew `setText`. A
1461
- * title screen greying out its own Continue button is the case that asked.
1462
- */
1463
- show(..._args) {
1464
- this.visible = true;
1465
- }
1466
- hide() {
1467
- this.visible = false;
1468
- }
1469
- static props = {
1470
- anchor: {
1471
- default: "topLeft",
1472
- options: [
1473
- "topLeft",
1474
- "top",
1475
- "topRight",
1476
- "left",
1477
- "center",
1478
- "right",
1479
- "bottomLeft",
1480
- "bottom",
1481
- "bottomRight"
1482
- ]
1483
- },
1484
- visible: { default: true },
1485
- /**
1486
- * Can menu navigation land on this? Interactive widgets default to true.
1487
- *
1488
- * A menu you can only click is not playable on a controller, and "add
1489
- * gamepad support" is not a thing a JSON scene could express at all.
1490
- */
1491
- focusable: { default: false },
1492
- /**
1493
- * Can the player pick this up and drop it somewhere? An inventory item is
1494
- * a `draggable` widget; a slot is a `dropTarget`.
1495
- */
1496
- draggable: { default: false },
1497
- /** Can something be dropped ON this? */
1498
- dropTarget: { default: false }
1499
- };
1500
- static signals = [
1501
- "dragStarted",
1502
- "dragCancelled",
1503
- "droppedOn",
1504
- "dropped"
1505
- ];
1506
- anchor = "topLeft";
1507
- visible = true;
1508
- focusable = false;
1509
- draggable = false;
1510
- dropTarget = false;
1511
- /** @internal Set by the owning HudLayer while this widget has focus. */
1512
- _focused = false;
1513
- /**
1514
- * @internal Confirm (`dir` 0) or nudge (-1 left / +1 right) — what pressing
1515
- * A or an arrow ON this widget means. Default: nothing.
1516
- */
1517
- _activate(_dir) {}
1518
- /** @internal Draw the focus ring. Overridable if a widget wants its own. */
1519
- _paintFocus() {
1520
- if (!this._element) return;
1521
- this._element.style.outline = this._focused ? "2px solid #6ee7dc" : "";
1522
- this._element.style.outlineOffset = this._focused ? "2px" : "";
1523
- }
1524
- /** @internal */
1525
- _element = null;
1526
- layer() {
1527
- for (let p = this.parent; p; p = p.parent) if (p instanceof HudLayer) return p;
1528
- return null;
1529
- }
1530
- onReady() {
1531
- if (!hasDom()) return;
1532
- this._mountWidget();
1533
- }
1534
- /**
1535
- * @internal Build and attach, once a host exists.
1536
- *
1537
- * A widget inside a UiPanel mounts into the PANEL, not the layer's anchor
1538
- * slot — that is what turns structure in the tree into structure on screen,
1539
- * and it is what a menu is. Ready order does not decide it: the panel builds
1540
- * on demand when a child asks (`_container`), and `update` retries for
1541
- * anything attached at runtime before its host existed.
1542
- */
1543
- _mountWidget() {
1544
- if (this._element || !hasDom()) return;
1545
- const host = this.host() ?? this.layer()?._slot(this.anchor);
1546
- if (!host) return;
1547
- this._element = this._build();
1548
- widgetOf.set(this._element, this);
1549
- if (this.draggable || this.dropTarget) this._wireDrag(this._element);
1550
- host.appendChild(this._element);
1551
- }
1552
- /**
1553
- * Drag and drop, on the DOM the engine already builds.
1554
- *
1555
- * An inventory is the one screen where "click it" is not enough, and every
1556
- * game that wanted one dropped out of scene JSON to hand-roll pointer
1557
- * handlers. The whole gesture is four signals and no new node type: a
1558
- * `draggable` widget emits `dragStarted` and, if it lands on one,
1559
- * `droppedOn(target)`; a `dropTarget` emits `dropped(source)`. Who owns the
1560
- * ITEM is the game's business — this reports the gesture, not a model.
1561
- */
1562
- _wireDrag(el) {
1563
- el.style.touchAction = "none";
1564
- if (this.draggable) {
1565
- el.style.cursor = "grab";
1566
- el.addEventListener("pointerdown", (event) => {
1567
- if (!this.draggable) return;
1568
- event.preventDefault();
1569
- capturePointer(el, event.pointerId);
1570
- dragging = this;
1571
- draggingFrom = dropTargetFromElement(el.parentElement);
1572
- el.style.opacity = "0.6";
1573
- this.emit("dragStarted", this);
1574
- });
1575
- el.addEventListener("pointerup", (event) => {
1576
- if (dragging !== this) return;
1577
- el.style.opacity = "";
1578
- el.releasePointerCapture?.(event.pointerId);
1579
- const under = document.elementFromPoint?.(event.clientX, event.clientY) ?? null;
1580
- const target = dropTargetFromElement(under) ?? widgetFromElement(under);
1581
- const origin = draggingFrom;
1582
- dragging = null;
1583
- draggingFrom = null;
1584
- if (target && target !== this && target !== origin && target.dropTarget) {
1585
- this.emit("droppedOn", target);
1586
- target.emit("dropped", this);
1587
- } else this.emit("dragCancelled", this);
1588
- });
1589
- }
1590
- }
1591
- /** The nearest ancestor that holds widgets itself (a UiPanel). */
1592
- host() {
1593
- for (let p = this.parent; p; p = p.parent) {
1594
- const holder = p;
1595
- if (typeof holder._container === "function") return holder._container();
1596
- if (p instanceof HudLayer) return null;
1597
- }
1598
- return null;
1599
- }
1600
- onExitTree() {
1601
- this._element?.remove();
1602
- this._element = null;
1603
- }
1604
- update(_dt) {
1605
- if (!this._element) {
1606
- this._mountWidget();
1607
- if (!this._element) return;
1608
- }
1609
- this._followAnchor();
1610
- this._sync();
1611
- if (this._element.style.display !== "none") this._shownDisplay = this._element.style.display;
1612
- this._element.style.display = this.visible ? this._shownDisplay : "none";
1613
- }
1614
- /**
1615
- * The display this widget's own css asks for while shown — `grid` for a grid
1616
- * panel, `flex` for a row, `''` for a widget that never says.
1617
- */
1618
- _shownDisplay = "";
1619
- /** Which anchor slot this widget was mounted into. */
1620
- mountedAnchor = null;
1621
- /**
1622
- * Move to the slot the anchor now names.
1623
- *
1624
- * `_mountWidget` read `anchor` once and `_element` is nulled only on
1625
- * `onExitTree`, so moving a widget from topLeft to bottomRight — from a
1626
- * behavior or from the editor's inspector, which live-patches string props —
1627
- * did nothing at all, forever.
1628
- */
1629
- _followAnchor() {
1630
- if (this.host()) return;
1631
- if (this.mountedAnchor === null) this.mountedAnchor = this.anchor;
1632
- if (this.mountedAnchor === this.anchor) return;
1633
- const slot = this.layer()?._slot(this.anchor);
1634
- if (!slot || !this._element) return;
1635
- this.mountedAnchor = this.anchor;
1636
- slot.appendChild(this._element);
1637
- }
1638
- _sync() {}
1639
- /**
1640
- * Resolve a text prop through the engine's locale.
1641
- *
1642
- * Called every frame from `_sync`, which is what makes switching language
1643
- * repaint with nothing to invalidate: widgets already re-read their props, so
1644
- * the new string simply appears on the next frame. A plain string costs one
1645
- * `startsWith` and is returned untouched.
1646
- */
1647
- _t(value) {
1648
- const locale = this.tree?.engine?.locale;
1649
- return locale ? locale.resolve(value) : value;
1650
- }
1651
- /**
1652
- * A translation with an English wording to fall back to.
1653
- *
1654
- * For text the ENGINE supplies — "Music", "Mute", "Unlimited" — where there
1655
- * is no author string to carry a `@t:` marker. Asks `declares()`, not
1656
- * `has()`: a key only another locale declares would otherwise come back as
1657
- * the raw key, which is worse than the English.
1658
- */
1659
- /**
1660
- * What this widget's label READS as — resolved every frame, never stored.
1661
- *
1662
- * `UiVolumeSlider` and `UiMuteToggle` used to compute their auto-label in
1663
- * `_build()` and write the result back into `this.label`, which broke them
1664
- * twice over: the per-frame `_t(this.label)` then had a plain string with no
1665
- * marker left to resolve, and `_build` runs from `onReady` — inside
1666
- * `loadScene`, BEFORE `setScene` merges the scene's `strings` — so the table
1667
- * was empty at bake time and the English was permanent even for a game that
1668
- * boots in another language.
1669
- */
1670
- /**
1671
- * What this widget actually PAINTS, when that differs from its props.
1672
- *
1673
- * A headless capture printed `text="@t:menu.start"` — byte-identical in
1674
- * every language — so a passing capture proved nothing about localization
1675
- * and a broken translation was invisible to every check the engine has.
1676
- * `incanto-frame` reads the WebGL buffer and cannot read DOM text at all.
1677
- *
1678
- * `undefined` when the painted words ARE the prop: recording it twice would
1679
- * be noise on every widget in a game that never localizes anything.
1680
- */
1681
- _paintedText() {}
1682
- _labelText() {
1683
- const label = this.label;
1684
- return typeof label === "string" ? this._t(label) : "";
1685
- }
1686
- _tOr(key, fallback) {
1687
- const locale = this.tree?.engine?.locale;
1688
- return locale?.declares(key) ? locale.t(key) : fallback;
1689
- }
1690
- };
1691
- /** A text line (score, timer, hints). Set `.text` from behaviors. */
1692
- var UiText = class extends HudWidgetBase {
1693
- static typeName = "UiText";
1694
- static props = {
1695
- ...HudWidgetBase.props,
1696
- text: { default: "" },
1697
- format: { default: "" },
1698
- size: { default: 16 },
1699
- color: { default: "#ffffff" },
1700
- shadow: { default: true }
1701
- };
1702
- text = "";
1703
- /**
1704
- * A template with a `{}` slot that `setText` fills — `"Gems {} / 8"`.
1705
- *
1706
- * Empty (the default) means `setText` replaces the whole line. Resolved at
1707
- * PAINT like `text`, so `"@t:hud.gems"` works and changing locale re-reads
1708
- * it: the value lives here, the words live in the strings table.
1709
- */
1710
- format = "";
1711
- size = 16;
1712
- color = "#ffffff";
1713
- shadow = true;
1714
- last = "\0";
1715
- /** null until something has actually set a value — see `shown`. */
1716
- slot = null;
1717
- /**
1718
- * Put a signal's value on screen — `scoreChanged → setText`.
1719
- *
1720
- * A `connections[]` handler must be a METHOD and `text` is a property, so the
1721
- * one wire every game needs (score to screen) could not be declared: every
1722
- * HUD began with a behavior whose whole job was one assignment.
1723
- */
1724
- setText(value) {
1725
- this.slot = String(value ?? "");
1726
- if (!this.format) this.text = this.slot;
1727
- }
1728
- /**
1729
- * What it actually paints, after the template and the locale.
1730
- *
1731
- * Before the first value arrives, a formatted widget shows `text` — its
1732
- * authored opening line. Filling the slot with nothing instead would put
1733
- * `"Gems / 8"` on screen for every game's first frame, and a score line
1734
- * that starts blank looks broken rather than empty.
1735
- */
1736
- get shown() {
1737
- if (this.format && this.slot !== null) return this._t(this.format).replace("{}", this.slot);
1738
- return this._t(this.text);
1739
- }
1740
- _paintedText() {
1741
- const painted = this.shown;
1742
- return painted === this.text ? void 0 : painted;
1743
- }
1744
- _build() {
1745
- const el = document.createElement("div");
1746
- el.style.cssText = this.look();
1747
- this.lastLook = this.look();
1748
- return el;
1749
- }
1750
- /** The whole appearance as one string, so re-applying is one comparison. */
1751
- look() {
1752
- return `font-size:${this.size}px;color:${this.color};${this.shadow ? "text-shadow:0 1px 3px rgba(0,0,0,.7);" : ""}white-space:pre;`;
1753
- }
1754
- lastLook = "";
1755
- _sync() {
1756
- const shown = this.shown;
1757
- if (shown !== this.last && this._element) {
1758
- this.last = shown;
1759
- this._element.textContent = shown;
1760
- }
1761
- const look = this.look();
1762
- if (look !== this.lastLook && this._element) {
1763
- this.lastLook = look;
1764
- this._element.style.cssText = look;
1765
- }
1766
- }
1767
- };
1768
- /**
1769
- * A bar fed the wrong argument shows a plausible wrong number forever.
1770
- *
1771
- * The trap is one signal away: `Health.damaged` is `(amount, current)`, so
1772
- * wiring it to `setValue` paints the DAMAGE as the health and looks like a
1773
- * working health bar. Say which signal has the shape a bar wants.
1774
- */
1775
- function requireNumber(value, node, method) {
1776
- if (typeof value === "number" && Number.isFinite(value)) return value;
1777
- throw new IncantoError("PROP_TYPE_MISMATCH", `UiBar '${node.getPath()}'.${method} needs a number, got ${JSON.stringify(value)}. Check what the wired signal passes FIRST — 'damaged' is (amount, current), 'healthChanged' is (current, max), which is the shape a bar wants.`);
1778
- }
1779
- /** A labeled progress bar (health, stamina, reload, boss HP). */
1780
- var UiBar = class extends HudWidgetBase {
1781
- static typeName = "UiBar";
1782
- static props = {
1783
- ...HudWidgetBase.props,
1784
- value: { default: 100 },
1785
- max: { default: 100 },
1786
- width: { default: 180 },
1787
- height: { default: 14 },
1788
- color: { default: "#4ade80" },
1789
- lowColor: { default: "#ef4444" },
1790
- lowThreshold: { default: .3 },
1791
- background: { default: "rgba(0,0,0,0.5)" },
1792
- label: { default: "" }
1793
- };
1794
- value = 100;
1795
- max = 100;
1796
- width = 180;
1797
- height = 14;
1798
- color = "#4ade80";
1799
- lowColor = "#ef4444";
1800
- lowThreshold = .3;
1801
- background = "rgba(0,0,0,0.5)";
1802
- label = "";
1803
- fill = null;
1804
- tag = null;
1805
- lastLabel = "\0";
1806
- lastRatio = -1;
1807
- /** Current fill ratio 0..1 (what the bar shows). */
1808
- get ratio() {
1809
- return this.max > 0 ? Math.min(1, Math.max(0, this.value / this.max)) : 0;
1810
- }
1811
- /**
1812
- * Drive the bar from a signal — `healthChanged → setValue`.
1813
- *
1814
- * Takes the max as a second argument because that is the shape the signal
1815
- * already has, so a bar whose ceiling moves (a max-HP upgrade) needs no
1816
- * second wire.
1817
- */
1818
- setValue(value, max) {
1819
- this.value = requireNumber(value, this, "setValue");
1820
- if (max !== void 0) this.max = requireNumber(max, this, "setValue");
1821
- }
1822
- /** Move the ceiling on its own, leaving the fill where it is. */
1823
- setMax(max) {
1824
- this.max = requireNumber(max, this, "setMax");
1825
- }
1826
- _build() {
1827
- const wrap = document.createElement("div");
1828
- wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
1829
- const tag = document.createElement("span");
1830
- tag.style.cssText = "color:#fff;font-size:12px;text-shadow:0 1px 3px rgba(0,0,0,.7);";
1831
- this.tag = tag;
1832
- wrap.appendChild(tag);
1833
- this.track = document.createElement("div");
1834
- this.track.style.cssText = this.trackLook();
1835
- this.lastTrackLook = this.trackLook();
1836
- this.fill = document.createElement("div");
1837
- this.fill.style.cssText = `height:100%;width:100%;background:${this.color};border-radius:inherit;transition:width .15s;`;
1838
- this.track.appendChild(this.fill);
1839
- wrap.appendChild(this.track);
1840
- return wrap;
1841
- }
1842
- track = null;
1843
- lastTrackLook = "";
1844
- lastFillColor = "";
1845
- /** The track's whole appearance, so re-applying is one comparison. */
1846
- trackLook() {
1847
- return `width:${this.width}px;height:${this.height}px;background:${this.background};border-radius:${this.height / 2}px;overflow:hidden;`;
1848
- }
1849
- _sync() {
1850
- const label = this._t(this.label);
1851
- if (label !== this.lastLabel && this.tag) {
1852
- this.lastLabel = label;
1853
- this.tag.textContent = label;
1854
- this.tag.style.display = label ? "" : "none";
1855
- }
1856
- const look = this.trackLook();
1857
- if (look !== this.lastTrackLook && this.track) {
1858
- this.lastTrackLook = look;
1859
- this.track.style.cssText = look;
1860
- }
1861
- const r = this.ratio;
1862
- if (!this.fill) return;
1863
- const colour = r <= this.lowThreshold ? this.lowColor : this.color;
1864
- if (colour !== this.lastFillColor) {
1865
- this.lastFillColor = colour;
1866
- this.fill.style.background = colour;
1867
- }
1868
- if (r === this.lastRatio) return;
1869
- this.lastRatio = r;
1870
- this.fill.style.width = `${r * 100}%`;
1871
- }
1872
- };
1873
- /**
1874
- * Center-screen announcements ("WAVE 2", "YOU DIED", "LEVEL UP") with fade
1875
- * in/out and a queue — call `.show(text, { color, seconds })`; `sticky:true`
1876
- * (seconds: 0) keeps it until the next show(). Emits `bannerShown(text)`.
1877
- */
1878
- var UiBanner = class extends HudWidgetBase {
1879
- static typeName = "UiBanner";
1880
- static signals = ["bannerShown"];
1881
- static props = {
1882
- ...HudWidgetBase.props,
1883
- anchor: {
1884
- default: "center",
1885
- options: [
1886
- "topLeft",
1887
- "top",
1888
- "topRight",
1889
- "left",
1890
- "center",
1891
- "right",
1892
- "bottomLeft",
1893
- "bottom",
1894
- "bottomRight"
1895
- ]
1896
- },
1897
- size: { default: 42 },
1898
- seconds: { default: 2 }
1899
- };
1900
- anchor = "center";
1901
- size = 42;
1902
- /** Default display time (per-show override via options). */
1903
- seconds = 2;
1904
- queue = [];
1905
- current = null;
1906
- remaining = 0;
1907
- /** What the banner is showing right now ('' when idle) — test-friendly. */
1908
- get showing() {
1909
- return this.current?.text ?? "";
1910
- }
1911
- show(text, opts) {
1912
- this.queue.push({
1913
- text,
1914
- source: text,
1915
- color: opts?.color ?? "#ffffff",
1916
- seconds: opts?.seconds ?? this.seconds
1917
- });
1918
- }
1919
- /** Drop everything (scene transitions). */
1920
- clear() {
1921
- this.queue.length = 0;
1922
- this.current = null;
1923
- this.remaining = 0;
1924
- if (this._element) this._element.style.opacity = "0";
1925
- }
1926
- _build() {
1927
- const el = document.createElement("div");
1928
- el.style.cssText = `font-size:${this.size}px;font-weight:800;color:#fff;text-shadow:0 2px 12px rgba(0,0,0,.8);opacity:0;transition:opacity .25s;text-align:center;`;
1929
- return el;
1930
- }
1931
- /** The whole look, so re-applying is one comparison. `size` was baked in at
1932
- * build, and a banner could never be resized. */
1933
- look() {
1934
- return `font-size:${this.size}px;font-weight:800;color:#fff;text-shadow:0 2px 12px rgba(0,0,0,.8);transition:opacity .25s;text-align:center;`;
1935
- }
1936
- lastLook = "";
1937
- update(dt) {
1938
- super.update(dt);
1939
- const look = this.look();
1940
- if (look !== this.lastLook && this._element) {
1941
- this.lastLook = look;
1942
- const opacity = this._element.style.opacity;
1943
- this._element.style.cssText = look;
1944
- this._element.style.opacity = opacity;
1945
- }
1946
- if (this.current) {
1947
- const next = this._t(this.current.source);
1948
- if (next !== this.current.text) {
1949
- this.current.text = next;
1950
- if (this._element) this._element.textContent = next;
1951
- }
1952
- if (this.current.seconds > 0) {
1953
- this.remaining -= dt;
1954
- if (this.remaining <= 0) {
1955
- this.current = null;
1956
- if (this._element) this._element.style.opacity = "0";
1957
- }
1958
- } else if (this.queue.length > 0) this.current = null;
1959
- }
1960
- if (!this.current && this.queue.length > 0) {
1961
- this.current = this.queue.shift();
1962
- this.current.text = this._t(this.current.source);
1963
- this.remaining = this.current.seconds;
1964
- if (this._element) {
1965
- this._element.textContent = this.current.text;
1966
- this._element.style.color = this.current.color;
1967
- this._element.style.opacity = "1";
1968
- }
1969
- this.emit("bannerShown", this.current.text);
1970
- }
1971
- }
1972
- };
1973
- //#endregion
1974
1338
  //#region src/core/effects-log.ts
1975
1339
  const EFFECT_LOG_LIMIT = 200;
1976
1340
  var EffectLog = class {
1977
1341
  events = [];
1342
+ /**
1343
+ * EVERY node that has fired, ever — a set of paths, not a window. See
1344
+ * `AudioBuses.sources`: 200 entries is a log, and "is this emitter wired?"
1345
+ * needs the whole run.
1346
+ *
1347
+ * AUTHORED paths, so it is bounded by the scene: a wave of forty enemies is
1348
+ * one emitter fired forty times. See `Node.wiringPath`.
1349
+ */
1350
+ sourcePaths = /* @__PURE__ */ new Set();
1978
1351
  /** @internal Called by the effects themselves; games read `recent()`. */
1979
1352
  record(event) {
1980
1353
  this.events.push(event);
1981
1354
  if (this.events.length > EFFECT_LOG_LIMIT) this.events.shift();
1355
+ this.sourcePaths.add(event.wiredAs ?? event.from);
1356
+ }
1357
+ /** Every node path that has fired an effect since the last `clearLog()`. */
1358
+ sources() {
1359
+ return [...this.sourcePaths];
1982
1360
  }
1983
1361
  /** The last `limit` effects, oldest first. */
1984
1362
  recent(limit = EFFECT_LOG_LIMIT) {
@@ -1995,6 +1373,7 @@ var EffectLog = class {
1995
1373
  /** Forget what has fired so far (between scenes, or between assertions). */
1996
1374
  clearLog() {
1997
1375
  this.events.length = 0;
1376
+ this.sourcePaths.clear();
1998
1377
  }
1999
1378
  };
2000
1379
  //#endregion
@@ -2031,6 +1410,25 @@ var FrameStatsRing = class {
2031
1410
  const span = this.spanMs();
2032
1411
  return span > 0 ? span / (this.count - 1) : 0;
2033
1412
  }
1413
+ /**
1414
+ * How long ago the newest sample arrived, in ms. `null` with no samples.
1415
+ *
1416
+ * `fps()` is a rate over a WINDOW, so it keeps answering after the frames
1417
+ * stop: a hidden tab, a descheduled loop, a page the browser froze. Measured
1418
+ * on an engine that ticked 40 times and then stopped being ticked:
1419
+ *
1420
+ * while ticking : fps 59.9
1421
+ * after 0 more ticks : fps 59.9 <- the last sample is 668 ms old
1422
+ *
1423
+ * Both numbers are true about the window they describe. What was missing was
1424
+ * any way to ask WHEN that window was, so "60 fps" read the same whether it
1425
+ * was measured 8 ms ago or four seconds ago.
1426
+ */
1427
+ ageMs(nowMs) {
1428
+ if (this.count === 0) return null;
1429
+ const newest = this.times[(this.head + this.count - 1) % this.capacity];
1430
+ return Math.max(0, nowMs - newest);
1431
+ }
2034
1432
  /** Forget all samples (engine.stop() — a paused game has no frame rate). */
2035
1433
  clear() {
2036
1434
  this.head = 0;
@@ -2387,6 +1785,28 @@ function qualityRendering(tier) {
2387
1785
  //#region src/core/engine.ts
2388
1786
  var engine_exports = /* @__PURE__ */ __exportAll({ Engine: () => Engine });
2389
1787
  const MAX_DT_SECONDS = .25;
1788
+ /**
1789
+ * The save API, called before the engine has a scene — which is what `onReady`
1790
+ * is.
1791
+ *
1792
+ * `loadScene(json, { engine })` attaches the engine to the TREE so a behavior
1793
+ * can use `this.engine` in `onReady`, and the ready pass runs there — before
1794
+ * `setScene` assigns `engine.scene`. So the natural place to put "continue my
1795
+ * save", the root behavior's own `onReady`, sees `scene === null`, and both
1796
+ * halves of the save API used to answer without a word:
1797
+ *
1798
+ * ```
1799
+ * captureState() {} <- an empty slot, written over a good one
1800
+ * restoreState(saved) missing: [every uid], expected: 0
1801
+ * ```
1802
+ *
1803
+ * The second is worse than silence: `missing` is documented as "a save naming a
1804
+ * uid this build deleted", so an idle game whose continue quietly did nothing
1805
+ * reported the shape of a save format change. Measured on a game built from the
1806
+ * tarball for exactly this — every number came back zero and every check was
1807
+ * green.
1808
+ */
1809
+ const NO_SCENE_YET = (what, consequence) => `[incanto] ${what}() ran before the engine had a scene, and ${consequence}. \`onReady\` fires while the scene is still being loaded — \`engine.scene\` is not assigned until \`setScene\`. Save and restore AFTER the game boots: from the \`sceneChanged\` signal, or after \`createGame2D\`/\`createGame3D\` resolves.`;
2390
1810
  function rafScheduler(cb) {
2391
1811
  let live = true;
2392
1812
  let id = requestAnimationFrame(function loop(t) {
@@ -2433,6 +1853,93 @@ var Engine = class {
2433
1853
  * learns about Rapier or three. From a behavior, prefer `this.physics`.
2434
1854
  */
2435
1855
  physics = null;
1856
+ /**
1857
+ * How to build a physics world for a scene that needs one.
1858
+ *
1859
+ * `createGame2D`/`createGame3D` install this; core never learns what Rapier
1860
+ * is. It exists because `physics: 'auto'` can only look at the scene the game
1861
+ * BOOTED with, and the most universal act of finishing a game is putting a
1862
+ * title screen in front of it:
1863
+ *
1864
+ * ```
1865
+ * after boot on the title : engine.physics = null
1866
+ * after goToScene into game: engine.physics = null
1867
+ * player y after 2 s : 0 -> 0
1868
+ * ```
1869
+ *
1870
+ * The game rendered perfectly and was inert — nothing fell, nothing collided,
1871
+ * nothing could be collected or hurt. One scene swap turned a working game
1872
+ * into a diorama, and nothing was logged.
1873
+ */
1874
+ physicsProvider = null;
1875
+ ensuring = null;
1876
+ /**
1877
+ * Give the current scene a physics world if it needs one and has none.
1878
+ *
1879
+ * Idempotent and safe to call on every swap: the provider decides whether the
1880
+ * incoming tree actually wants physics, and an explicit `physics: false` at
1881
+ * boot installs no provider at all, so opting out stays opted out.
1882
+ *
1883
+ * Asynchronous because the Rapier WASM has to load. The first frames after a
1884
+ * swap therefore run unsimulated — which every AI behaviour already tolerates
1885
+ * (`moveBody` falls back to a direct write when `_physics` is null) and which
1886
+ * is a hitch, where the alternative was a game that never simulated at all.
1887
+ */
1888
+ ensurePhysics() {
1889
+ if (this.physics || !this.physicsProvider || !this._scene) return Promise.resolve();
1890
+ if (this.ensuring) return this.ensuring;
1891
+ const root = this._scene.root;
1892
+ this.ensuring = this.physicsProvider(root).catch((e) => {
1893
+ this.log.error(`[incanto] could not enable physics for this scene: ${String(e?.message ?? e)}`);
1894
+ }).finally(() => {
1895
+ this.ensuring = null;
1896
+ });
1897
+ return this.ensuring;
1898
+ }
1899
+ /**
1900
+ * The seed this engine was built with — game logic draws from `rng`, and
1901
+ * anything DECORATIVE derives its own stream from this instead.
1902
+ *
1903
+ * A particle emitter used to draw from `rng`, and it spawns a number of
1904
+ * particles that depends on `dt`. Measured on the same scene and seed, the
1905
+ * same simulated two seconds, comparing the world's own draws by update
1906
+ * index:
1907
+ *
1908
+ * ```
1909
+ * particles off: 60fps and 30fps identical
1910
+ * particles ON : they diverge at update 2
1911
+ * ```
1912
+ *
1913
+ * So a purely visual effect changed the game's random decisions, and the
1914
+ * PLAYER'S FRAME RATE decided them. Derived streams stay reproducible for
1915
+ * replays and screenshots without spending the one the game reads.
1916
+ */
1917
+ seed;
1918
+ /**
1919
+ * How to resolve an `instance:` sub-scene, remembered from the boot load.
1920
+ *
1921
+ * `loadScene(json, { engine, resolveScene })` records it here, so the paths
1922
+ * that RELOAD — `restartScene`, `goToScene`, a game-over restart key — can
1923
+ * pass it back. Without that a game using the documented sub-scenes booted
1924
+ * fine and threw `UNRESOLVED_INSTANCE` on its first restart.
1925
+ */
1926
+ resolveScene;
1927
+ /**
1928
+ * How the current scene was LOADED, for the rebuilds that happen later.
1929
+ *
1930
+ * `duplicateNode` — every `Spawner`, every `NetworkSpawner`, every clone a
1931
+ * game makes — rebuilds a subtree from scene JSON long after `loadScene`
1932
+ * returned, and it did so with no options at all. Under
1933
+ * `stubMissingBehaviors` (how EVERY headless check runs a game whose scripts
1934
+ * are TypeScript it cannot import) the first spawn met the real registry and
1935
+ * threw `UNKNOWN_BEHAVIOR` out of `update`, disabling the spawner for the
1936
+ * rest of the run — a wave game reported with no waves and nothing saying
1937
+ * why.
1938
+ *
1939
+ * Recorded here for the same reason as `resolveScene` above: the engine
1940
+ * outlives the call, and the rebuild paths can reach it.
1941
+ */
1942
+ loadOptions = {};
2436
1943
  /** Seeded randomness for game logic (deterministic when `seed` is set). */
2437
1944
  rng;
2438
1945
  /** The engine log channel (debug overlay + headless harness tail this). */
@@ -2447,6 +1954,57 @@ var Engine = class {
2447
1954
  * and cleared on dispose; null when nothing renders (headless).
2448
1955
  */
2449
1956
  picker = null;
1957
+ /**
1958
+ * Screen pixel → a point in the WORLD, when a renderer has offered one.
1959
+ *
1960
+ * `picker` answers "which node"; this answers "which place", and a mouse game
1961
+ * needs both — drag-and-launch, drop a tower on the map, steer a cursor unit,
1962
+ * draw the slingshot band. The conversion has always existed
1963
+ * (`Renderer2D.worldFromScreen` / `Renderer3D.worldFromScreen`) and lived
1964
+ * only on the renderer, so a Behavior — which holds a node and an engine and
1965
+ * nothing else — could not call it. Its two workarounds were both forbidden:
1966
+ * stash the renderer in a module global, or hand-roll the projection the 2D
1967
+ * skill says in so many words not to hand-roll.
1968
+ *
1969
+ * The answer is the scene's own units and shape: `[x, y]` in 2D (design
1970
+ * pixels), `[x, y, z]` on the ground plane in 3D — the same array a node's
1971
+ * `position` is, so it goes straight back into one.
1972
+ *
1973
+ * Instance-scoped, set by the renderer on construction and cleared on
1974
+ * dispose. Headless a harness may install a geometric one; null when nothing
1975
+ * has.
1976
+ */
1977
+ toWorld = null;
1978
+ /**
1979
+ * A world point → the screen pixel drawing it. The inverse of `toWorld`, for
1980
+ * pinning DOM to a node or placing a marker over one.
1981
+ *
1982
+ * `behind` is true when the point is behind the camera (3D); a 2D renderer
1983
+ * always reports false.
1984
+ */
1985
+ toScreen = null;
1986
+ /**
1987
+ * Where the cursor is in the world, or null.
1988
+ *
1989
+ * The two-line dance every mouse game writes — read the pointer, convert it —
1990
+ * with the two ways it goes wrong already handled: a pointer nothing has
1991
+ * moved yet is null rather than the origin, and no converter is null rather
1992
+ * than a guess.
1993
+ */
1994
+ /**
1995
+ * Screen pixel → a ray into the world, when a renderer has offered one.
1996
+ *
1997
+ * `toWorld` lands on a plane; this is the general form, and it is what a shot
1998
+ * aimed at what the cursor is OVER needs: feed it straight to
1999
+ * `engine.physics.castRay(origin, dir, …)`. 3D only — a 2D renderer installs
2000
+ * nothing here, since a 2D world has no depth to cast into.
2001
+ */
2002
+ screenRay = null;
2003
+ pointerWorld() {
2004
+ const at = this.input.pointerPosition();
2005
+ if (!at || !this.toWorld) return null;
2006
+ return this.toWorld(at.x, at.y);
2007
+ }
2450
2008
  /** Frames the engine has stepped — monotonic, and the pick cache's key. */
2451
2009
  frameNumber = 0;
2452
2010
  /** @internal frame number + answer, so N Clickables cost ONE raycast. */
@@ -2535,7 +2093,35 @@ var Engine = class {
2535
2093
  * (hit-stop / pause). Scales BOTH variable and fixed updates — physics,
2536
2094
  * timers, behaviors all breathe together. See `gameplay` `hitStop()`.
2537
2095
  */
2538
- timeScale = 1;
2096
+ get timeScale() {
2097
+ return this._timeScale;
2098
+ }
2099
+ set timeScale(v) {
2100
+ if (!Number.isFinite(v)) {
2101
+ diagnose(this, "warn", `[incanto] timeScale must be a finite number, got ${v} — keeping ${this._timeScale}.`);
2102
+ return;
2103
+ }
2104
+ this._timeScale = v;
2105
+ this._timeScaleWrites += 1;
2106
+ }
2107
+ _timeScale = 1;
2108
+ _timeScaleWrites = 0;
2109
+ /**
2110
+ * How many times anyone has written `timeScale` — the clock's ownership tag.
2111
+ *
2112
+ * A temporary freeze cannot tell its own 0 from somebody else's by VALUE.
2113
+ * `hitStop(engine)` then `flow.gameOver()` is the ordinary killing blow, and
2114
+ * both set 0; the freeze then wrote its captured scale back five frames later
2115
+ * and the world started moving again behind a sticky GAME OVER banner —
2116
+ * enemies walking, timers running, the player watching their corpse get shot.
2117
+ *
2118
+ * A counter answers the question the value cannot: *has anyone touched this
2119
+ * since I did?* Cheap (one increment per write, and writes are rare) and it
2120
+ * needs no registry of who holds what.
2121
+ */
2122
+ get timeScaleWrites() {
2123
+ return this._timeScaleWrites;
2124
+ }
2539
2125
  /**
2540
2126
  * The node the dev overlay has selected (renderers draw its bounding box
2541
2127
  * as an orange outline in the game view so you can SEE what you picked).
@@ -2558,11 +2144,39 @@ var Engine = class {
2558
2144
  get unscaledTime() {
2559
2145
  return this._unscaledTime;
2560
2146
  }
2147
+ /**
2148
+ * THIS frame's dt in real seconds — the one a paused game still gets.
2149
+ *
2150
+ * `update(dt)` receives the SCALED dt, which is right for anything that is
2151
+ * part of the simulation and wrong for everything that is a presentation of
2152
+ * it. At `timeScale = 0` — which is what `GameFlow.pause()` and, by default,
2153
+ * `gameOver()` set — a scaled countdown does not count:
2154
+ *
2155
+ * a timed UiBanner still up when the player dies never expires, so the
2156
+ * flow's own sticky GAME OVER banner is queued behind it and NEVER
2157
+ * PAINTED — the ending screen shows the last wave toast, forever
2158
+ * a live CameraShake never settles: `t -= dt` is a no-op while the rest of
2159
+ * update() re-rolls a full-magnitude offset every rendered frame, and
2160
+ * with FollowCamera's smoothing that is an unbounded random walk
2161
+ * a remote player's interpolation stops, so everyone else is a statue while
2162
+ * their real positions keep arriving
2163
+ *
2164
+ * `hitStop`, `AudioPlayer` and `music.tick` already use the real clock for
2165
+ * exactly this reason. This is the same clock, per frame, so a countdown can
2166
+ * use it without reading `unscaledTime` twice and subtracting.
2167
+ */
2168
+ get unscaledDelta() {
2169
+ return this._unscaledDelta;
2170
+ }
2171
+ _unscaledDelta = 0;
2561
2172
  constructor(opts = {}) {
2562
- this.fixedStep = 1 / (opts.fixedHz ?? 60);
2173
+ const hz = opts.fixedHz ?? 60;
2174
+ if (!Number.isFinite(hz) || hz <= 0) throw new IncantoError("PROP_TYPE_MISMATCH", `fixedHz must be a finite positive rate in Hz, got ${hz}. 60 is the default; 30 halves physics cost.`);
2175
+ this.fixedStep = 1 / hz;
2563
2176
  this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
2564
2177
  this.scheduler = opts.scheduler ?? rafScheduler;
2565
- this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
2178
+ this.seed = opts.seed ?? Math.random() * 4294967295 >>> 0;
2179
+ this.rng = new Rng(this.seed);
2566
2180
  this.maxFps = opts.maxFps ?? 0;
2567
2181
  this.settings = opts.settings instanceof Settings ? opts.settings : new Settings(opts.settings?.namespace ?? "settings");
2568
2182
  this.sfx._setBuses(this.audio);
@@ -2576,17 +2190,30 @@ var Engine = class {
2576
2190
  /** Replace the active scene. The previous scene's root is freed. */
2577
2191
  setScene(scene) {
2578
2192
  if (scene === this._scene) return;
2579
- this._scene?.tree._setEngine(null);
2580
- this._scene?.root.free();
2193
+ const leaving = this._scene;
2194
+ if (this.swapping) {
2195
+ diagnose(this, "error", "[incanto] setScene() was called from inside a scene teardown — ignoring the inner call. Free a scene once; wire the next one from `sceneChanged` instead.");
2196
+ return;
2197
+ }
2198
+ this.swapping = true;
2199
+ try {
2200
+ leaving?.root.free();
2201
+ leaving?.tree._setEngine(null);
2202
+ } finally {
2203
+ this.swapping = false;
2204
+ }
2581
2205
  this._scene = scene;
2582
2206
  scene.tree._setEngine(this);
2583
2207
  this.input.clear();
2584
2208
  this._time = 0;
2585
2209
  this._unscaledTime = 0;
2586
2210
  this.timeScale = 1;
2211
+ this.accumulator = 0;
2587
2212
  if (scene.input) this.input.declare(scene.input);
2588
2213
  if (scene.strings) this.locale.load(scene.strings);
2589
2214
  this.orderGroups = resolveOrderGroups(scene.orderGroups);
2215
+ this.audio.clearLog();
2216
+ this.effects.clearLog();
2590
2217
  this.sceneChanged.emit(scene);
2591
2218
  }
2592
2219
  start() {
@@ -2663,6 +2290,29 @@ var Engine = class {
2663
2290
  _nodeErrored(_node) {
2664
2291
  this.errorCount += 1;
2665
2292
  }
2293
+ /**
2294
+ * @internal Something OUTSIDE a frame failed — a replication send that
2295
+ * rejected, a transport that refused. Count it and say it once.
2296
+ *
2297
+ * `stats().errors` is what every harness in the ecosystem ends with, and it
2298
+ * only ever counted things that threw INSIDE a frame. A multiplayer client
2299
+ * whose socket died kept running perfectly on its own screen while every
2300
+ * other player watched it frozen, and `errors: 0` with an empty log said the
2301
+ * game was fine. Repeats are counted and not re-printed, for the reason
2302
+ * `reportFrameError` gives: sixty identical traces a second is how you lose
2303
+ * the first one.
2304
+ */
2305
+ _asyncFailed(what, error) {
2306
+ this.errorCount += 1;
2307
+ const line = `${what}: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`;
2308
+ if (line === this.lastAsyncError) return;
2309
+ this.lastAsyncError = line;
2310
+ this.log.error(`[incanto] ${line}`);
2311
+ console.error(`[incanto] ${line}`);
2312
+ }
2313
+ lastAsyncError = null;
2314
+ /** Re-entrancy guard for `setScene` — an exit hook can now reach the engine. */
2315
+ swapping = false;
2666
2316
  errorCount = 0;
2667
2317
  lastFrameError = null;
2668
2318
  /**
@@ -2688,25 +2338,51 @@ var Engine = class {
2688
2338
  * core/save-slots.ts for why a save carries state rather than a tree
2689
2339
  * snapshot.
2690
2340
  */
2341
+ /**
2342
+ * Behaviors whose `serialize()` threw during the LAST `captureState()`.
2343
+ *
2344
+ * A write path that cannot see part of the run must not overwrite the slot
2345
+ * that still can — `SavePoint.save()` reads this and refuses.
2346
+ */
2347
+ get lastCaptureFailures() {
2348
+ return this._captureFailures;
2349
+ }
2350
+ _captureFailures = [];
2691
2351
  captureState() {
2692
2352
  const root = this.scene?.root;
2693
- if (!root) return {};
2694
- const { state, unaddressable, unrecordable } = captureBehaviors(root, this.scene?.source);
2353
+ if (!root) {
2354
+ diagnose(this, "error", NO_SCENE_YET("captureState", "saved an EMPTY slot"));
2355
+ return {};
2356
+ }
2357
+ const { state, unaddressable, unrecordable, failed } = captureBehaviors(root, this.scene?.source, this.scene?.authoredUids);
2695
2358
  for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
2359
+ this._captureFailures = failed;
2360
+ if (failed.length > 0) diagnose(this, "error", `[incanto] save: ${failed.length} behavior(s) threw out of serialize(), so their state is NOT in this save: ${failed.slice(0, 4).join("; ")}${failed.length > 4 ? `, +${failed.length - 4} more` : ""}.`);
2696
2361
  if (unrecordable.length > 0) diagnose(this, "error", `[incanto] save: ${unrecordable.length} authored node(s) this run consumed have no uid, so nothing records that they are gone and LOADING THIS SAVE BRINGS THEM BACK: ${unrecordable.slice(0, 8).join(", ")}${unrecordable.length > 8 ? `, +${unrecordable.length - 8} more` : ""}. Give each one a "uid" from newUid().`);
2362
+ state[ENGINE_STATE_KEY] = { rng: this.rng.position };
2697
2363
  return state;
2698
2364
  }
2699
2365
  /** Give it back, after the scene is loaded and onReady has run. */
2700
2366
  restoreState(state) {
2367
+ const engineState = state[ENGINE_STATE_KEY];
2368
+ if (typeof engineState?.rng === "number") this.rng.position = engineState.rng;
2701
2369
  const root = this.scene?.root;
2702
- if (!root) return {
2703
- missing: Object.keys(state),
2704
- skipped: [],
2705
- restored: 0,
2706
- expected: 0,
2707
- freed: 0
2708
- };
2709
- return restoreBehaviors(root, state);
2370
+ if (!root) {
2371
+ diagnose(this, "error", NO_SCENE_YET("restoreState", "restored NOTHING"));
2372
+ return {
2373
+ missing: Object.keys(state).filter((k) => k !== ENGINE_STATE_KEY),
2374
+ skipped: [],
2375
+ refused: [],
2376
+ unannounced: [],
2377
+ stale: [],
2378
+ restored: 0,
2379
+ expected: 0,
2380
+ freed: 0
2381
+ };
2382
+ }
2383
+ const report = restoreBehaviors(root, state, this.scene?.authoredUids);
2384
+ if (report.stale.length > 0) diagnose(this, "warn", `[incanto] restore: ${report.stale.length} authored node(s) are missing from this scene and the save does not say they were consumed — this scene has been PLAYED since it loaded, so the restore leaves THIS run's changes in place. Reload the scene first (GameFlow.restart with restoreOnReady, or restartScene(engine) then restore) — note the SavePoint node is a NEW one after a restart. Starting with '${report.stale[0]}'.`);
2385
+ return report;
2710
2386
  }
2711
2387
  erroredNodes() {
2712
2388
  const out = [];
@@ -2756,6 +2432,7 @@ var Engine = class {
2756
2432
  return {
2757
2433
  fps: this.frameStats.fps(),
2758
2434
  frameMs: this.frameStats.frameMs(),
2435
+ lastFrameAgeMs: this.frameStats.ageMs(performance.now()),
2759
2436
  nodes: this._scene?.tree.root ? countNodes(this._scene.tree.root) : 0,
2760
2437
  running: this.disposeScheduler !== null,
2761
2438
  errors: this.errorCount,
@@ -2780,11 +2457,18 @@ var Engine = class {
2780
2457
  */
2781
2458
  dispose() {
2782
2459
  this.stop();
2460
+ const physics = this.physics;
2461
+ this.physics = null;
2462
+ try {
2463
+ physics?.dispose();
2464
+ } catch (error) {
2465
+ diagnose(this, "warn", "[incanto] teardown: physics failed (continuing)", error);
2466
+ }
2783
2467
  this.music.dispose();
2784
2468
  this.sfx.dispose();
2785
2469
  if (this._scene) {
2786
- this._scene.tree._setEngine(null);
2787
2470
  this._scene.root.free();
2471
+ this._scene.tree._setEngine(null);
2788
2472
  this._scene = null;
2789
2473
  this.sceneChanged.emit(null);
2790
2474
  }
@@ -2807,13 +2491,18 @@ var Engine = class {
2807
2491
  const dt = this.fixedStep * (opts.ignorePause ? 1 : this.timeScale);
2808
2492
  this._time += dt;
2809
2493
  this._unscaledTime += this.fixedStep;
2494
+ this._unscaledDelta = this.fixedStep;
2810
2495
  const startedFixed = performance.now();
2496
+ this.input.beginFrame();
2497
+ this.input.beginFixedStep();
2811
2498
  scene.tree.fixedUpdate(dt);
2812
2499
  this.fixedUpdated.emit(dt);
2500
+ this.input.endFixedStep();
2813
2501
  const startedUpdate = performance.now();
2814
2502
  this.frameNumber += 1;
2815
2503
  scene.tree.update(dt);
2816
- this.music.tick(dt);
2504
+ this.music.tick(this.fixedStep);
2505
+ this.sfx._tick(this.fixedStep);
2817
2506
  this.updated.emit(dt);
2818
2507
  const ended = performance.now();
2819
2508
  const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
@@ -2828,19 +2517,25 @@ var Engine = class {
2828
2517
  this.lastMs = nowMs;
2829
2518
  return;
2830
2519
  }
2831
- const rawDt = Math.max(0, Math.min((nowMs - this.lastMs) / 1e3, MAX_DT_SECONDS));
2520
+ const raw = (nowMs - this.lastMs) / 1e3;
2521
+ if (!Number.isFinite(raw)) diagnose(this, "warn", `[incanto] tick() was handed a non-finite timestamp (${nowMs}); this frame is skipped. A NaN here used to stop every fixed step for the life of the engine.`);
2522
+ const rawDt = Number.isFinite(raw) ? Math.max(0, Math.min(raw, MAX_DT_SECONDS)) : 0;
2832
2523
  const dt = rawDt * this.timeScale;
2833
2524
  this._time += dt;
2834
2525
  this._unscaledTime += rawDt;
2526
+ this._unscaledDelta = rawDt;
2835
2527
  this.lastMs = nowMs;
2836
2528
  const scene = this._scene;
2837
2529
  if (!scene) return;
2530
+ this.input.beginFrame();
2838
2531
  this.accumulator += dt;
2839
2532
  let steps = 0;
2840
2533
  const startedFixed = performance.now();
2841
2534
  while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
2535
+ this.input.beginFixedStep();
2842
2536
  scene.tree.fixedUpdate(this.fixedStep);
2843
2537
  this.fixedUpdated.emit(this.fixedStep);
2538
+ this.input.endFixedStep();
2844
2539
  this.accumulator -= this.fixedStep;
2845
2540
  steps += 1;
2846
2541
  }
@@ -2848,7 +2543,8 @@ var Engine = class {
2848
2543
  const startedUpdate = performance.now();
2849
2544
  this.frameNumber += 1;
2850
2545
  scene.tree.update(dt);
2851
- this.music.tick(dt);
2546
+ this.music.tick(rawDt);
2547
+ this.sfx._tick(rawDt);
2852
2548
  this.updated.emit(dt);
2853
2549
  const ended = performance.now();
2854
2550
  const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
@@ -2887,12 +2583,24 @@ var AudioPlayer = class extends Node {
2887
2583
  static typeName = "AudioPlayer";
2888
2584
  static signals = ["finished"];
2889
2585
  static props = {
2890
- src: { default: "" },
2586
+ src: {
2587
+ default: "",
2588
+ asset: {
2589
+ kind: "audio",
2590
+ urlOnly: true
2591
+ }
2592
+ },
2891
2593
  preset: {
2892
2594
  default: "custom",
2893
2595
  options: ["custom", ...SFX_PRESET_NAMES]
2894
2596
  },
2895
- volume: { default: 1 },
2597
+ volume: {
2598
+ default: 1,
2599
+ range: {
2600
+ min: 0,
2601
+ max: 1
2602
+ }
2603
+ },
2896
2604
  pitch: { default: 1 },
2897
2605
  seed: { default: 0 },
2898
2606
  bus: {
@@ -3006,8 +2714,10 @@ var AudioPlayer = class extends Node {
3006
2714
  kind,
3007
2715
  name,
3008
2716
  from: this.getPath(),
2717
+ wiredAs: this.wiringPath(),
3009
2718
  bus: this.bus === "music" ? "music" : "sfx",
3010
- at: engine.time
2719
+ at: engine.time,
2720
+ ...this.loop ? { loop: true } : {}
3011
2721
  });
3012
2722
  }
3013
2723
  /** Final gain = engine buses × this volume (1 when not in a tree). */
@@ -3040,31 +2750,57 @@ var AudioPlayer = class extends Node {
3040
2750
  };
3041
2751
  }
3042
2752
  /**
3043
- * Start the sound. `at` schedules it on the AUDIO clock instead of now.
2753
+ * Start the sound, now.
2754
+ *
2755
+ * **Takes no arguments, on purpose.** This is the method scenes wire signals
2756
+ * to — `incanto-audio.md` teaches
2757
+ * `{"signal": "collected", "from": "Player/Collector", "to": "Coin",
2758
+ * "handler": "play"}` — and a signal hands its handler whatever it carries.
2759
+ * `collected` leads with a NUMBER. When this briefly took an optional
2760
+ * scheduling time, that wiring became `play(10)`, which scheduled the pickup
2761
+ * sound at ABSOLUTE audio-clock second 10: inaudible for the first ten
2762
+ * seconds of the game and fine thereafter. The same optional parameter also
2763
+ * made `Function.length` 1, so the engine's own arity checker cried wolf on
2764
+ * the shipped `platformer-2d` template's own harness.
2765
+ *
2766
+ * Scheduling has its own name: {@link playAt}.
2767
+ */
2768
+ /**
2769
+ * Start the sound at `when` on the AUDIO clock (`engine.sfx.now` + a lead).
3044
2770
  *
3045
2771
  * A frame is a 16.67 ms grid at 60 Hz and 33.33 ms at 30, so a sound fired
3046
2772
  * from `update()` cannot land closer than one frame to where a chart wants
3047
- * it — measured 0 of 64 notes scheduled on the documented path. Queue it a
3048
- * lead ahead of `engine.sfx.now` and the same notes land at |mean| 0.000000
3049
- * ms:
2773
+ * it — measured 0 of 64 notes given a scheduled start. Queue them a lead
2774
+ * ahead and the same notes land at |mean| 0.000000 ms:
3050
2775
  *
3051
2776
  * ```ts
3052
- * const lead = 0.08; // schedule this far ahead
3053
- * for (const note of dueSoon(engine.sfx.now + lead)) {
3054
- * hit.play(engine.sfx.now + (note.atSec - songSeconds));
2777
+ * const LEAD = 0.08;
2778
+ * for (const note of dueSoon(engine.sfx.now + LEAD)) {
2779
+ * hit.playAt(startedAt + note.atSec);
3055
2780
  * }
3056
2781
  * ```
3057
2782
  *
3058
2783
  * PRESETS only — a `src` clip goes through an `<audio>` element, which has no
3059
- * scheduling clock, and passing `at` for one is ignored rather than silently
3060
- * approximated. Everything else is unchanged: the bus gain applies, the sound
3061
- * lands in `engine.audio.recent()`, and `muted` still silences it, none of
3062
- * which a hand-rolled `AudioContext` scheduler keeps.
2784
+ * scheduling clock, so `playAt` on one plays immediately rather than being
2785
+ * silently approximated. A time already past plays now (Web Audio's own
2786
+ * rule), so a late scheduler is late, not silent. The bus gain, the
2787
+ * `engine.audio.recent()` entry and `muted` all still apply.
2788
+ *
2789
+ * Separate from {@link play} because `play` is what scenes wire signals to,
2790
+ * and a signal hands its handler whatever it carries.
3063
2791
  */
3064
- play(at) {
2792
+ playAt(when) {
2793
+ if (this.preset !== "custom") {
2794
+ this.note("preset", this.preset);
2795
+ this.playPreset(Number.isFinite(when) ? when : void 0);
2796
+ return;
2797
+ }
2798
+ this.play();
2799
+ }
2800
+ play() {
3065
2801
  if (this.preset !== "custom") {
3066
2802
  this.note("preset", this.preset);
3067
- this.playPreset(at);
2803
+ this.playPreset();
3068
2804
  return;
3069
2805
  }
3070
2806
  if (!this.src) return;
@@ -3231,7 +2967,7 @@ var UiButton = class extends HudWidgetBase {
3231
2967
  /** The whole look, so re-applying is one comparison. `size`/`color` were baked
3232
2968
  * in at build, and a button could never be restyled afterwards. */
3233
2969
  look() {
3234
- return `pointer-events:auto;cursor:pointer;font:600 ${this.size}px system-ui,sans-serif;color:${this.color};background:${this.background};border:1px solid rgba(255,255,255,0.25);border-radius:8px;padding:8px 18px;transition:transform .08s, background .15s;`;
2970
+ return `${TAPPABLE}cursor:pointer;font:600 ${this.size}px system-ui,sans-serif;color:${this.color};background:${this.background};border:1px solid rgba(255,255,255,0.25);border-radius:8px;padding:8px 18px;transition:transform .08s, background .15s;`;
3235
2971
  }
3236
2972
  lastLook = "";
3237
2973
  _sync() {
@@ -3308,7 +3044,9 @@ var UiDialogue = class extends HudWidgetBase {
3308
3044
  return this.current ? this.current.text.slice(0, Math.floor(this.revealed)) : "";
3309
3045
  }
3310
3046
  /** Queue a line; optional `choices` renders buttons after the line types out. */
3311
- say(speaker, text, choices) {
3047
+ say(speaker, text, choices = void 0) {
3048
+ requireText(speaker, this, "UiDialogue.say (speaker)");
3049
+ requireText(text, this, "UiDialogue.say (text)");
3312
3050
  const source = {
3313
3051
  speaker,
3314
3052
  text,
@@ -3339,6 +3077,16 @@ var UiDialogue = class extends HudWidgetBase {
3339
3077
  * `dir` is 0 for "activate" and ±1 for a nudge, the same shape `UiSelect`
3340
3078
  * uses — so one key layout drives a menu, a slider and a conversation.
3341
3079
  */
3080
+ /**
3081
+ * A click on the BOX is `advance()`, which refuses to skip a choice.
3082
+ *
3083
+ * The DOM handler is `box.addEventListener('click', () => this.advance())`
3084
+ * and `advance` returns early while `current.choices` is set. Enter is a
3085
+ * different verb — it answers — so a harness clicking the box must not.
3086
+ */
3087
+ _click() {
3088
+ this.advance();
3089
+ }
3342
3090
  _activate(dir) {
3343
3091
  if (!this.active || !this.current) return;
3344
3092
  const choices = this.current.choices;
@@ -3399,7 +3147,7 @@ var UiDialogue = class extends HudWidgetBase {
3399
3147
  }
3400
3148
  _build() {
3401
3149
  const box = document.createElement("div");
3402
- box.style.cssText = `pointer-events:auto;width:${this.width}px;max-width:86vw;background:rgba(12,14,22,0.92);border:1px solid rgba(255,255,255,0.2);border-radius:12px;padding:14px 18px;display:none;font:15px/1.5 system-ui,sans-serif;color:#fff;cursor:pointer;user-select:none;`;
3150
+ box.style.cssText = `${TAPPABLE}width:${this.width}px;${FITS}background:rgba(12,14,22,0.92);border:1px solid rgba(255,255,255,0.2);border-radius:12px;padding:14px 18px;display:none;font:15px/1.5 system-ui,sans-serif;color:#fff;cursor:pointer;user-select:none;`;
3403
3151
  this.speakerEl = document.createElement("div");
3404
3152
  this.speakerEl.style.cssText = "font-weight:800;color:#ffd166;margin-bottom:4px;font-size:13px;";
3405
3153
  this.textEl = document.createElement("div");
@@ -3453,7 +3201,7 @@ var UiDialogue = class extends HudWidgetBase {
3453
3201
  const b = document.createElement("button");
3454
3202
  b.type = "button";
3455
3203
  b.textContent = label;
3456
- b.style.cssText = "pointer-events:auto;cursor:pointer;font:600 13px system-ui,sans-serif;color:#fff;background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.3);border-radius:8px;padding:6px 14px;";
3204
+ b.style.cssText = `${TAPPABLE}cursor:pointer;font:600 13px system-ui,sans-serif;color:#fff;background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.3);border-radius:8px;padding:6px 14px;`;
3457
3205
  b.addEventListener("click", (e) => {
3458
3206
  e.stopPropagation();
3459
3207
  this.choose(i);
@@ -3637,14 +3385,27 @@ var Timer = class extends Node {
3637
3385
  autostart = false;
3638
3386
  running = false;
3639
3387
  remaining = 0;
3640
- start(time) {
3641
- if (time !== void 0) this.waitTime = time;
3388
+ start(time = this.waitTime) {
3389
+ this.waitTime = time;
3642
3390
  this.remaining = this.waitTime;
3643
3391
  this.running = true;
3644
3392
  }
3645
3393
  stop() {
3646
3394
  this.running = false;
3647
3395
  }
3396
+ /**
3397
+ * Scene-load validation: a period of zero is a timer that never fires.
3398
+ *
3399
+ * `update` guards it — a non-positive period "would spin forever on any dt",
3400
+ * so it calls `stop()` — and the guard is right. Being SILENT about it is
3401
+ * what a load error is for: a scene asking for a tick every zero seconds got
3402
+ * no ticks and no word said. The runtime guard stays, for code that zeroes
3403
+ * the period from a handler.
3404
+ */
3405
+ static validateJson(node) {
3406
+ const timer = node;
3407
+ if (!(timer.waitTime > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `Timer '${node.name}': "waitTime" must be > 0, got ${timer.waitTime} — a timer with no period never fires (it stops itself on the first frame).`, { prop: "waitTime" });
3408
+ }
3648
3409
  onReady() {
3649
3410
  if (this.autostart) this.start();
3650
3411
  }
@@ -3665,6 +3426,20 @@ var Timer = class extends Node {
3665
3426
  };
3666
3427
  //#endregion
3667
3428
  //#region src/core/nodes/ui-kit.ts
3429
+ /**
3430
+ * The widgets a MENU is made of.
3431
+ *
3432
+ * The HUD kit could say things — text, a bar, a banner, a button — and could not
3433
+ * arrange them or take a value back. So every title screen, pause menu, options
3434
+ * panel, inventory grid and shop in every game built on this engine was
3435
+ * hand-rolled DOM: `star-survivor`'s level-up menu builds its own elements, which
3436
+ * drops it out of scene JSON, out of the editor, out of `incanto-check`, and out
3437
+ * of every headless verification the agent has.
3438
+ *
3439
+ * These are the missing pieces, and they are deliberately the SAME kind of thing
3440
+ * the existing widgets are — nodes in the tree, props in JSON, DOM built by the
3441
+ * engine — so an inventory is a scene, not a component library.
3442
+ */
3668
3443
  function px(n) {
3669
3444
  return `${n}px`;
3670
3445
  }
@@ -3734,7 +3509,7 @@ var UiPanel = class extends HudWidgetBase {
3734
3509
  return this._element;
3735
3510
  }
3736
3511
  css() {
3737
- return `${this.layout === "grid" ? `display:grid;grid-template-columns:repeat(${Math.max(1, this.columns)},auto);` : `display:flex;flex-direction:${this.layout};align-items:stretch;`}gap:${px(this.gap)};padding:${px(this.padding)};background:${this.background};border-radius:${px(this.radius)};` + (this.width > 0 ? `width:${px(this.width)};` : "") + (this.height > 0 ? `height:${px(this.height)};` : "") + (this.border ? `border:${this.border};` : "") + "box-sizing:border-box;pointer-events:auto;";
3512
+ return `${this.layout === "grid" ? `display:grid;grid-template-columns:repeat(${Math.max(1, this.columns)},auto);` : `display:flex;flex-direction:${this.layout};align-items:stretch;`}gap:${px(this.gap)};padding:${px(this.padding)};background:${this.background};border-radius:${px(this.radius)};` + (this.width > 0 ? `width:${px(this.width)};` : "") + `${FITS}max-height:calc(100vh - 32px);overflow:auto;` + (this.height > 0 ? `height:${px(this.height)};` : "") + (this.border ? `border:${this.border};` : "") + `box-sizing:border-box;${TAPPABLE}`;
3738
3513
  }
3739
3514
  _sync() {
3740
3515
  const next = this.css();
@@ -3766,7 +3541,13 @@ var UiImage = class extends HudWidgetBase {
3766
3541
  },
3767
3542
  /** Multiply the image by a colour: greyed-out items, team tints. */
3768
3543
  tint: { default: "" },
3769
- opacity: { default: 1 }
3544
+ opacity: {
3545
+ default: 1,
3546
+ range: {
3547
+ min: 0,
3548
+ max: 1
3549
+ }
3550
+ }
3770
3551
  };
3771
3552
  src = "";
3772
3553
  width = 48;
@@ -3790,7 +3571,7 @@ var UiImage = class extends HudWidgetBase {
3790
3571
  }
3791
3572
  css() {
3792
3573
  const url = this.resolved();
3793
- return `width:${px(this.width)};height:${px(this.height)};flex:none;` + (url ? `background-image:url("${url}");` : "") + `background-size:${this.fit === "fill" ? "100% 100%" : this.fit};background-position:center;background-repeat:no-repeat;opacity:${this.opacity};` + (this.tint ? `background-color:${this.tint};background-blend-mode:multiply;` : "");
3574
+ return `width:${px(this.width)};height:${px(this.height)};flex:none;${FITS}` + (url ? `background-image:url("${url}");` : "") + `background-size:${this.fit === "fill" ? "100% 100%" : this.fit};background-position:center;background-repeat:no-repeat;opacity:${this.opacity};` + (this.tint ? `background-color:${this.tint};background-blend-mode:multiply;` : "");
3794
3575
  }
3795
3576
  _sync() {
3796
3577
  const next = this.key();
@@ -3843,7 +3624,7 @@ var UiSlider = class extends HudWidgetBase {
3843
3624
  last = NaN;
3844
3625
  _build() {
3845
3626
  const wrap = document.createElement("div");
3846
- wrap.style.cssText = "display:flex;align-items:center;gap:8px;pointer-events:auto;font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;";
3627
+ wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
3847
3628
  const text = document.createElement("span");
3848
3629
  text.textContent = this._labelText();
3849
3630
  const input = document.createElement("input");
@@ -3852,7 +3633,7 @@ var UiSlider = class extends HudWidgetBase {
3852
3633
  input.max = String(this.max);
3853
3634
  input.step = String(this.step);
3854
3635
  input.value = String(this.value);
3855
- input.style.cssText = `width:${px(this.width)};accent-color:${this.color};`;
3636
+ input.style.cssText = `width:${px(this.width)};accent-color:${this.color};${FITS}`;
3856
3637
  input.addEventListener("input", () => {
3857
3638
  this.value = Number(input.value);
3858
3639
  this.last = this.value;
@@ -3900,7 +3681,7 @@ var UiToggle = class extends HudWidgetBase {
3900
3681
  last = null;
3901
3682
  _build() {
3902
3683
  const wrap = document.createElement("label");
3903
- wrap.style.cssText = "display:flex;align-items:center;gap:8px;pointer-events:auto;cursor:pointer;font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;";
3684
+ wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}cursor:pointer;font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
3904
3685
  const input = document.createElement("input");
3905
3686
  input.type = "checkbox";
3906
3687
  input.checked = this.value;
@@ -3948,6 +3729,29 @@ var UiSelect = class extends HudWidgetBase {
3948
3729
  options = "";
3949
3730
  value = "";
3950
3731
  focusable = true;
3732
+ /** The options, as the list of values it actually offers. */
3733
+ choices() {
3734
+ return this.options.split(",").map((o) => o.trim()).filter(Boolean);
3735
+ }
3736
+ /**
3737
+ * A value the list cannot show is a setting the game and the screen disagree
3738
+ * about.
3739
+ *
3740
+ * The browser cannot select an option that does not exist, so it shows the
3741
+ * FIRST one — while `node.value` keeps answering with what it was given, and
3742
+ * `changed` only fires when a person moves it, so nothing ever reconciles
3743
+ * them. That is the shape a saved setting has after a patch removes a quality
3744
+ * tier, and it is silent for the rest of the session.
3745
+ *
3746
+ * A hard error at LOAD, like every other enum in this engine. An empty value
3747
+ * is "nothing chosen yet", which is a choice.
3748
+ */
3749
+ static validateJson(node) {
3750
+ const self = node;
3751
+ const choices = self.choices();
3752
+ if (self.value === "" || choices.length === 0 || choices.includes(self.value)) return;
3753
+ throw new IncantoError("PROP_TYPE_MISMATCH", `UiSelect '${node.name}' has value '${self.value}', which is not one of its options [${choices.join(", ")}] — the browser would show the first one instead, and the game would go on believing the value it was given.`, { prop: "value" });
3754
+ }
3951
3755
  /** Left/right (and confirm) walk the list — a select has no other gesture. */
3952
3756
  _activate(dir) {
3953
3757
  const choices = this.options.split(",").map((o) => o.trim()).filter(Boolean);
@@ -3966,7 +3770,7 @@ var UiSelect = class extends HudWidgetBase {
3966
3770
  last = "\0";
3967
3771
  _build() {
3968
3772
  const wrap = document.createElement("label");
3969
- wrap.style.cssText = "display:flex;align-items:center;gap:8px;pointer-events:auto;font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;";
3773
+ wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
3970
3774
  const text = document.createElement("span");
3971
3775
  this.labelEl = text;
3972
3776
  const select = document.createElement("select");
@@ -4010,6 +3814,25 @@ var UiSelect = class extends HudWidgetBase {
4010
3814
  if (this.value) select.value = this.value;
4011
3815
  this.last = this.value;
4012
3816
  }
3817
+ /** What was already reported, so a stuck value is said once and not per frame. */
3818
+ complainedAbout = "\0";
3819
+ /**
3820
+ * The runtime half of `validateJson`.
3821
+ *
3822
+ * A behaviour may legitimately write the value first and widen `options`
3823
+ * after — so this reports rather than refuses, and goes quiet again when the
3824
+ * list catches up.
3825
+ */
3826
+ reportMismatch() {
3827
+ const choices = this.choices();
3828
+ if (!(this.value !== "" && choices.length > 0 && !choices.includes(this.value))) {
3829
+ this.complainedAbout = "\0";
3830
+ return;
3831
+ }
3832
+ if (this.complainedAbout === this.value) return;
3833
+ this.complainedAbout = this.value;
3834
+ diagnose(this.tree?.engine ?? null, "error", `[incanto] UiSelect '${this.getPath()}' has value '${this.value}', which is not one of its options [${choices.join(", ")}]. The menu shows the first one instead, so the game and the screen now disagree about this setting.`);
3835
+ }
4013
3836
  _sync() {
4014
3837
  const label = this._t(this.label);
4015
3838
  if (label !== this.lastLabel && this.labelEl) {
@@ -4022,6 +3845,9 @@ var UiSelect = class extends HudWidgetBase {
4022
3845
  this.select.value = this.value;
4023
3846
  }
4024
3847
  }
3848
+ _pull() {
3849
+ this.reportMismatch();
3850
+ }
4025
3851
  };
4026
3852
  /** Endonyms: `ko` must read as the Korean word to someone who cannot read `Korean`. */
4027
3853
  const ENDONYMS = {
@@ -4067,21 +3893,19 @@ var UiLanguageSelect = class extends UiSelect {
4067
3893
  label = "Language";
4068
3894
  /** Last locale we PUSHED into `value`, to tell an engine change from a player one. */
4069
3895
  lastLocale = "";
4070
- _sync() {
3896
+ _pull() {
4071
3897
  const locale = this.tree?.engine?.locale;
4072
- if (locale) {
4073
- const available = locale.available();
4074
- const options = available.join(",");
4075
- if (options !== this.options) this.options = options;
4076
- if (locale.locale !== this.lastLocale) {
4077
- this.lastLocale = locale.locale;
4078
- this.value = locale.locale;
4079
- } else if (this.value !== locale.locale && available.includes(this.value)) {
4080
- locale.locale = this.value;
4081
- this.lastLocale = this.value;
4082
- }
3898
+ if (!locale) return;
3899
+ const available = locale.available();
3900
+ const options = available.join(",");
3901
+ if (options !== this.options) this.options = options;
3902
+ if (locale.locale !== this.lastLocale) {
3903
+ this.lastLocale = locale.locale;
3904
+ this.value = locale.locale;
3905
+ } else if (this.value !== locale.locale && available.includes(this.value)) {
3906
+ locale.locale = this.value;
3907
+ this.lastLocale = this.value;
4083
3908
  }
4084
- super._sync();
4085
3909
  }
4086
3910
  _optionLabel(value) {
4087
3911
  return ENDONYMS[value] ?? value;
@@ -4112,18 +3936,19 @@ var UiSettingSelect = class extends UiSelect {
4112
3936
  * `settings.quality.high` is worse than one reading `High`. English is the
4113
3937
  * base language and the fallback, always.
4114
3938
  */
4115
- _sync() {
3939
+ _pull() {
4116
3940
  const engine = this._engine();
4117
- if (engine) {
4118
- const stored = this._read(engine);
4119
- if (stored !== this.lastPushed) {
4120
- this.lastPushed = stored;
4121
- this.value = stored;
4122
- } else if (this.value !== stored) {
4123
- this._write(engine, this.value);
4124
- this.lastPushed = this._read(engine);
4125
- }
3941
+ if (!engine) return;
3942
+ const stored = this._read(engine);
3943
+ if (stored !== this.lastPushed) {
3944
+ this.lastPushed = stored;
3945
+ this.value = stored;
3946
+ } else if (this.value !== stored) {
3947
+ this._write(engine, this.value);
3948
+ this.lastPushed = this._read(engine);
4126
3949
  }
3950
+ }
3951
+ _sync() {
4127
3952
  super._sync();
4128
3953
  }
4129
3954
  };
@@ -4286,19 +4111,17 @@ var UiVolumeSlider = class extends UiSlider {
4286
4111
  const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
4287
4112
  return this._tOr(`settings.volume.${this.bus}`, english);
4288
4113
  }
4289
- _sync() {
4114
+ _pull() {
4290
4115
  const audio = this.tree?.engine?.audio;
4291
- if (audio) {
4292
- const stored = audio[this.bus];
4293
- if (stored !== this.lastPushed) {
4294
- this.lastPushed = stored;
4295
- this.value = stored;
4296
- } else if (this.value !== stored) {
4297
- audio[this.bus] = this.value;
4298
- this.lastPushed = audio[this.bus];
4299
- }
4116
+ if (!audio) return;
4117
+ const stored = audio[this.bus];
4118
+ if (stored !== this.lastPushed) {
4119
+ this.lastPushed = stored;
4120
+ this.value = stored;
4121
+ } else if (this.value !== stored) {
4122
+ audio[this.bus] = this.value;
4123
+ this.lastPushed = audio[this.bus];
4300
4124
  }
4301
- super._sync();
4302
4125
  }
4303
4126
  };
4304
4127
  /**
@@ -4321,18 +4144,16 @@ var UiMuteToggle = class extends UiToggle {
4321
4144
  _labelText() {
4322
4145
  return this.label ? this._t(this.label) : this._tOr("settings.mute", "Mute");
4323
4146
  }
4324
- _sync() {
4147
+ _pull() {
4325
4148
  const audio = this.tree?.engine?.audio;
4326
- if (audio) {
4327
- if (audio.muted !== this.lastPushed) {
4328
- this.lastPushed = audio.muted;
4329
- this.value = audio.muted;
4330
- } else if (this.value !== audio.muted) {
4331
- audio.muted = this.value;
4332
- this.lastPushed = audio.muted;
4333
- }
4149
+ if (!audio) return;
4150
+ if (audio.muted !== this.lastPushed) {
4151
+ this.lastPushed = audio.muted;
4152
+ this.value = audio.muted;
4153
+ } else if (this.value !== audio.muted) {
4154
+ audio.muted = this.value;
4155
+ this.lastPushed = audio.muted;
4334
4156
  }
4335
- super._sync();
4336
4157
  }
4337
4158
  };
4338
4159
  //#endregion
@@ -4366,4 +4187,4 @@ function registerCoreNodes() {
4366
4187
  registerNode(UiMuteToggle);
4367
4188
  }
4368
4189
  //#endregion
4369
- export { UiBanner as A, isAudioContextAvailable as B, qualityRendering as C, EffectLog as D, LogManager as E, T_PREFIX as F, SFX_PRESET_NAMES as G, spatialGain as H, suggestLocale as I, MusicManager as J, sfxDuration as K, translateOn as L, UiText as M, BASE_LOCALE as N, HudLayer as O, Localization as P, AudioBuses as Q, translationKey as R, qualityEnvironment as S, suggestQuality as T, spatialPan as U, ROLLOFF_MODELS as V, SFX_PRESETS as W, crossfadeGains as X, WebAudioMusicBackend as Y, fadeGain as Z, AudioPlayer as _, UiMuteToggle as a, Settings as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, UiDialogue as g, UiButton as h, UiLanguageSelect as i, UiBar as j, HudWidgetBase as k, UiSelect as l, Respawn as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, synthSfx as q, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, Engine as v, readDeviceHints as w, qualityCaps as x, engine_exports as y, SfxEngine as z };
4190
+ export { T_PREFIX as A, SFX_PRESET_NAMES as B, qualityRendering as C, EffectLog as D, LogManager as E, isAudioContextAvailable as F, crossfadeGains as G, synthSfx as H, ROLLOFF_MODELS as I, fadeGain as K, spatialGain as L, translateOn as M, translationKey as N, BASE_LOCALE as O, SfxEngine as P, spatialPan as R, qualityEnvironment as S, suggestQuality as T, MusicManager as U, sfxDuration as V, WebAudioMusicBackend as W, AudioPlayer as _, UiMuteToggle as a, Settings as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, UiDialogue as g, UiButton as h, UiLanguageSelect as i, suggestLocale as j, Localization as k, UiSelect as l, Respawn as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, AudioBuses as q, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, Engine as v, readDeviceHints as w, qualityCaps as x, engine_exports as y, SFX_PRESETS as z };