incanto 0.69.0 → 0.71.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 (147) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1088 -882
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.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-DZE7B07H.js";
1
+ import { o as __exportAll } from "./json-CfTjpvW8.js";
2
+ import { B as HudLayer, F as resolveOrderGroups, G as UiText, H as TAPPABLE, I as InputMap, J as Node, N as ORDER_GROUP_BASE, U as UiBanner, V as HudWidgetBase, W as UiBar, Y as diagnose, a as restoreBehaviors, ct as Signal, i as captureBehaviors, j as createSaveStore, q as requireText, t as ENGINE_STATE_KEY, z as FITS } from "./save-slots-Bvuh2p_r.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
@@ -2406,6 +1785,28 @@ function qualityRendering(tier) {
2406
1785
  //#region src/core/engine.ts
2407
1786
  var engine_exports = /* @__PURE__ */ __exportAll({ Engine: () => Engine });
2408
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.`;
2409
1810
  function rafScheduler(cb) {
2410
1811
  let live = true;
2411
1812
  let id = requestAnimationFrame(function loop(t) {
@@ -2495,6 +1896,50 @@ var Engine = class {
2495
1896
  });
2496
1897
  return this.ensuring;
2497
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 = {};
2498
1943
  /** Seeded randomness for game logic (deterministic when `seed` is set). */
2499
1944
  rng;
2500
1945
  /** The engine log channel (debug overlay + headless harness tail this). */
@@ -2509,6 +1954,57 @@ var Engine = class {
2509
1954
  * and cleared on dispose; null when nothing renders (headless).
2510
1955
  */
2511
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
+ }
2512
2008
  /** Frames the engine has stepped — monotonic, and the pick cache's key. */
2513
2009
  frameNumber = 0;
2514
2010
  /** @internal frame number + answer, so N Clickables cost ONE raycast. */
@@ -2597,7 +2093,35 @@ var Engine = class {
2597
2093
  * (hit-stop / pause). Scales BOTH variable and fixed updates — physics,
2598
2094
  * timers, behaviors all breathe together. See `gameplay` `hitStop()`.
2599
2095
  */
2600
- 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
+ }
2601
2125
  /**
2602
2126
  * The node the dev overlay has selected (renderers draw its bounding box
2603
2127
  * as an orange outline in the game view so you can SEE what you picked).
@@ -2620,11 +2144,39 @@ var Engine = class {
2620
2144
  get unscaledTime() {
2621
2145
  return this._unscaledTime;
2622
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;
2623
2172
  constructor(opts = {}) {
2624
- 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;
2625
2176
  this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
2626
2177
  this.scheduler = opts.scheduler ?? rafScheduler;
2627
- 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);
2628
2180
  this.maxFps = opts.maxFps ?? 0;
2629
2181
  this.settings = opts.settings instanceof Settings ? opts.settings : new Settings(opts.settings?.namespace ?? "settings");
2630
2182
  this.sfx._setBuses(this.audio);
@@ -2638,17 +2190,30 @@ var Engine = class {
2638
2190
  /** Replace the active scene. The previous scene's root is freed. */
2639
2191
  setScene(scene) {
2640
2192
  if (scene === this._scene) return;
2641
- this._scene?.tree._setEngine(null);
2642
- 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
+ }
2643
2205
  this._scene = scene;
2644
2206
  scene.tree._setEngine(this);
2645
2207
  this.input.clear();
2646
2208
  this._time = 0;
2647
2209
  this._unscaledTime = 0;
2648
2210
  this.timeScale = 1;
2211
+ this.accumulator = 0;
2649
2212
  if (scene.input) this.input.declare(scene.input);
2650
2213
  if (scene.strings) this.locale.load(scene.strings);
2651
2214
  this.orderGroups = resolveOrderGroups(scene.orderGroups);
2215
+ this.audio.clearLog();
2216
+ this.effects.clearLog();
2652
2217
  this.sceneChanged.emit(scene);
2653
2218
  }
2654
2219
  start() {
@@ -2725,6 +2290,29 @@ var Engine = class {
2725
2290
  _nodeErrored(_node) {
2726
2291
  this.errorCount += 1;
2727
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;
2728
2316
  errorCount = 0;
2729
2317
  lastFrameError = null;
2730
2318
  /**
@@ -2750,25 +2338,51 @@ var Engine = class {
2750
2338
  * core/save-slots.ts for why a save carries state rather than a tree
2751
2339
  * snapshot.
2752
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 = [];
2753
2351
  captureState() {
2754
2352
  const root = this.scene?.root;
2755
- if (!root) return {};
2756
- 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);
2757
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` : ""}.`);
2758
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 };
2759
2363
  return state;
2760
2364
  }
2761
2365
  /** Give it back, after the scene is loaded and onReady has run. */
2762
2366
  restoreState(state) {
2367
+ const engineState = state[ENGINE_STATE_KEY];
2368
+ if (typeof engineState?.rng === "number") this.rng.position = engineState.rng;
2763
2369
  const root = this.scene?.root;
2764
- if (!root) return {
2765
- missing: Object.keys(state),
2766
- skipped: [],
2767
- restored: 0,
2768
- expected: 0,
2769
- freed: 0
2770
- };
2771
- 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;
2772
2386
  }
2773
2387
  erroredNodes() {
2774
2388
  const out = [];
@@ -2843,13 +2457,24 @@ var Engine = class {
2843
2457
  */
2844
2458
  dispose() {
2845
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
+ }
2846
2467
  this.music.dispose();
2847
2468
  this.sfx.dispose();
2848
2469
  if (this._scene) {
2849
- this._scene.tree._setEngine(null);
2850
2470
  this._scene.root.free();
2471
+ this._scene.tree._setEngine(null);
2851
2472
  this._scene = null;
2852
- this.sceneChanged.emit(null);
2473
+ try {
2474
+ this.sceneChanged.emit(null);
2475
+ } catch (error) {
2476
+ diagnose(this, "warn", "[incanto] teardown: a `sceneChanged` listener threw (continuing) — it fires with NULL here, so a handler that reads the scene has to check for it", error);
2477
+ }
2853
2478
  }
2854
2479
  this.input.dispose();
2855
2480
  this.input.clear();
@@ -2870,14 +2495,18 @@ var Engine = class {
2870
2495
  const dt = this.fixedStep * (opts.ignorePause ? 1 : this.timeScale);
2871
2496
  this._time += dt;
2872
2497
  this._unscaledTime += this.fixedStep;
2498
+ this._unscaledDelta = this.fixedStep;
2873
2499
  const startedFixed = performance.now();
2874
2500
  this.input.beginFrame();
2501
+ this.input.beginFixedStep();
2875
2502
  scene.tree.fixedUpdate(dt);
2876
2503
  this.fixedUpdated.emit(dt);
2504
+ this.input.endFixedStep();
2877
2505
  const startedUpdate = performance.now();
2878
2506
  this.frameNumber += 1;
2879
2507
  scene.tree.update(dt);
2880
- this.music.tick(dt);
2508
+ this.music.tick(this.fixedStep);
2509
+ this.sfx._tick(this.fixedStep);
2881
2510
  this.updated.emit(dt);
2882
2511
  const ended = performance.now();
2883
2512
  const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
@@ -2892,10 +2521,13 @@ var Engine = class {
2892
2521
  this.lastMs = nowMs;
2893
2522
  return;
2894
2523
  }
2895
- const rawDt = Math.max(0, Math.min((nowMs - this.lastMs) / 1e3, MAX_DT_SECONDS));
2524
+ const raw = (nowMs - this.lastMs) / 1e3;
2525
+ 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.`);
2526
+ const rawDt = Number.isFinite(raw) ? Math.max(0, Math.min(raw, MAX_DT_SECONDS)) : 0;
2896
2527
  const dt = rawDt * this.timeScale;
2897
2528
  this._time += dt;
2898
2529
  this._unscaledTime += rawDt;
2530
+ this._unscaledDelta = rawDt;
2899
2531
  this.lastMs = nowMs;
2900
2532
  const scene = this._scene;
2901
2533
  if (!scene) return;
@@ -2904,8 +2536,10 @@ var Engine = class {
2904
2536
  let steps = 0;
2905
2537
  const startedFixed = performance.now();
2906
2538
  while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
2539
+ this.input.beginFixedStep();
2907
2540
  scene.tree.fixedUpdate(this.fixedStep);
2908
2541
  this.fixedUpdated.emit(this.fixedStep);
2542
+ this.input.endFixedStep();
2909
2543
  this.accumulator -= this.fixedStep;
2910
2544
  steps += 1;
2911
2545
  }
@@ -2913,7 +2547,8 @@ var Engine = class {
2913
2547
  const startedUpdate = performance.now();
2914
2548
  this.frameNumber += 1;
2915
2549
  scene.tree.update(dt);
2916
- this.music.tick(dt);
2550
+ this.music.tick(rawDt);
2551
+ this.sfx._tick(rawDt);
2917
2552
  this.updated.emit(dt);
2918
2553
  const ended = performance.now();
2919
2554
  const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
@@ -2952,12 +2587,24 @@ var AudioPlayer = class extends Node {
2952
2587
  static typeName = "AudioPlayer";
2953
2588
  static signals = ["finished"];
2954
2589
  static props = {
2955
- src: { default: "" },
2590
+ src: {
2591
+ default: "",
2592
+ asset: {
2593
+ kind: "audio",
2594
+ urlOnly: true
2595
+ }
2596
+ },
2956
2597
  preset: {
2957
2598
  default: "custom",
2958
2599
  options: ["custom", ...SFX_PRESET_NAMES]
2959
2600
  },
2960
- volume: { default: 1 },
2601
+ volume: {
2602
+ default: 1,
2603
+ range: {
2604
+ min: 0,
2605
+ max: 1
2606
+ }
2607
+ },
2961
2608
  pitch: { default: 1 },
2962
2609
  seed: { default: 0 },
2963
2610
  bus: {
@@ -3071,6 +2718,7 @@ var AudioPlayer = class extends Node {
3071
2718
  kind,
3072
2719
  name,
3073
2720
  from: this.getPath(),
2721
+ wiredAs: this.wiringPath(),
3074
2722
  bus: this.bus === "music" ? "music" : "sfx",
3075
2723
  at: engine.time,
3076
2724
  ...this.loop ? { loop: true } : {}
@@ -3323,7 +2971,7 @@ var UiButton = class extends HudWidgetBase {
3323
2971
  /** The whole look, so re-applying is one comparison. `size`/`color` were baked
3324
2972
  * in at build, and a button could never be restyled afterwards. */
3325
2973
  look() {
3326
- 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;`;
2974
+ 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;`;
3327
2975
  }
3328
2976
  lastLook = "";
3329
2977
  _sync() {
@@ -3400,7 +3048,9 @@ var UiDialogue = class extends HudWidgetBase {
3400
3048
  return this.current ? this.current.text.slice(0, Math.floor(this.revealed)) : "";
3401
3049
  }
3402
3050
  /** Queue a line; optional `choices` renders buttons after the line types out. */
3403
- say(speaker, text, choices) {
3051
+ say(speaker, text, choices = void 0) {
3052
+ requireText(speaker, this, "UiDialogue.say (speaker)");
3053
+ requireText(text, this, "UiDialogue.say (text)");
3404
3054
  const source = {
3405
3055
  speaker,
3406
3056
  text,
@@ -3431,6 +3081,16 @@ var UiDialogue = class extends HudWidgetBase {
3431
3081
  * `dir` is 0 for "activate" and ±1 for a nudge, the same shape `UiSelect`
3432
3082
  * uses — so one key layout drives a menu, a slider and a conversation.
3433
3083
  */
3084
+ /**
3085
+ * A click on the BOX is `advance()`, which refuses to skip a choice.
3086
+ *
3087
+ * The DOM handler is `box.addEventListener('click', () => this.advance())`
3088
+ * and `advance` returns early while `current.choices` is set. Enter is a
3089
+ * different verb — it answers — so a harness clicking the box must not.
3090
+ */
3091
+ _click() {
3092
+ this.advance();
3093
+ }
3434
3094
  _activate(dir) {
3435
3095
  if (!this.active || !this.current) return;
3436
3096
  const choices = this.current.choices;
@@ -3491,7 +3151,7 @@ var UiDialogue = class extends HudWidgetBase {
3491
3151
  }
3492
3152
  _build() {
3493
3153
  const box = document.createElement("div");
3494
- 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;`;
3154
+ 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;`;
3495
3155
  this.speakerEl = document.createElement("div");
3496
3156
  this.speakerEl.style.cssText = "font-weight:800;color:#ffd166;margin-bottom:4px;font-size:13px;";
3497
3157
  this.textEl = document.createElement("div");
@@ -3545,7 +3205,7 @@ var UiDialogue = class extends HudWidgetBase {
3545
3205
  const b = document.createElement("button");
3546
3206
  b.type = "button";
3547
3207
  b.textContent = label;
3548
- 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;";
3208
+ 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;`;
3549
3209
  b.addEventListener("click", (e) => {
3550
3210
  e.stopPropagation();
3551
3211
  this.choose(i);
@@ -3729,14 +3389,27 @@ var Timer = class extends Node {
3729
3389
  autostart = false;
3730
3390
  running = false;
3731
3391
  remaining = 0;
3732
- start(time) {
3733
- if (time !== void 0) this.waitTime = time;
3392
+ start(time = this.waitTime) {
3393
+ this.waitTime = time;
3734
3394
  this.remaining = this.waitTime;
3735
3395
  this.running = true;
3736
3396
  }
3737
3397
  stop() {
3738
3398
  this.running = false;
3739
3399
  }
3400
+ /**
3401
+ * Scene-load validation: a period of zero is a timer that never fires.
3402
+ *
3403
+ * `update` guards it — a non-positive period "would spin forever on any dt",
3404
+ * so it calls `stop()` — and the guard is right. Being SILENT about it is
3405
+ * what a load error is for: a scene asking for a tick every zero seconds got
3406
+ * no ticks and no word said. The runtime guard stays, for code that zeroes
3407
+ * the period from a handler.
3408
+ */
3409
+ static validateJson(node) {
3410
+ const timer = node;
3411
+ 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" });
3412
+ }
3740
3413
  onReady() {
3741
3414
  if (this.autostart) this.start();
3742
3415
  }
@@ -3757,6 +3430,20 @@ var Timer = class extends Node {
3757
3430
  };
3758
3431
  //#endregion
3759
3432
  //#region src/core/nodes/ui-kit.ts
3433
+ /**
3434
+ * The widgets a MENU is made of.
3435
+ *
3436
+ * The HUD kit could say things — text, a bar, a banner, a button — and could not
3437
+ * arrange them or take a value back. So every title screen, pause menu, options
3438
+ * panel, inventory grid and shop in every game built on this engine was
3439
+ * hand-rolled DOM: `star-survivor`'s level-up menu builds its own elements, which
3440
+ * drops it out of scene JSON, out of the editor, out of `incanto-check`, and out
3441
+ * of every headless verification the agent has.
3442
+ *
3443
+ * These are the missing pieces, and they are deliberately the SAME kind of thing
3444
+ * the existing widgets are — nodes in the tree, props in JSON, DOM built by the
3445
+ * engine — so an inventory is a scene, not a component library.
3446
+ */
3760
3447
  function px(n) {
3761
3448
  return `${n}px`;
3762
3449
  }
@@ -3826,7 +3513,7 @@ var UiPanel = class extends HudWidgetBase {
3826
3513
  return this._element;
3827
3514
  }
3828
3515
  css() {
3829
- 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;";
3516
+ 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}`;
3830
3517
  }
3831
3518
  _sync() {
3832
3519
  const next = this.css();
@@ -3858,7 +3545,13 @@ var UiImage = class extends HudWidgetBase {
3858
3545
  },
3859
3546
  /** Multiply the image by a colour: greyed-out items, team tints. */
3860
3547
  tint: { default: "" },
3861
- opacity: { default: 1 }
3548
+ opacity: {
3549
+ default: 1,
3550
+ range: {
3551
+ min: 0,
3552
+ max: 1
3553
+ }
3554
+ }
3862
3555
  };
3863
3556
  src = "";
3864
3557
  width = 48;
@@ -3882,7 +3575,7 @@ var UiImage = class extends HudWidgetBase {
3882
3575
  }
3883
3576
  css() {
3884
3577
  const url = this.resolved();
3885
- 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;` : "");
3578
+ 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;` : "");
3886
3579
  }
3887
3580
  _sync() {
3888
3581
  const next = this.key();
@@ -3923,7 +3616,20 @@ var UiSlider = class extends HudWidgetBase {
3923
3616
  /** Left/right nudge by one step — the only sane thing a d-pad can mean here. */
3924
3617
  _activate(dir) {
3925
3618
  if (dir === 0) return;
3926
- const next = Math.min(this.max, Math.max(this.min, this.value + dir * this.step));
3619
+ this.choose(this.value + dir * this.step);
3620
+ }
3621
+ /**
3622
+ * Move it as a PERSON would: set the value and say so.
3623
+ *
3624
+ * `UiButton` has `press()` and the value widgets had nothing, because
3625
+ * assigning `.value` deliberately does not emit (restoring a saved setting
3626
+ * must not fire the handler that saved it). So a harness — or a game driving
3627
+ * its own menu: a preset button, "reset to defaults", a tutorial that moves a
3628
+ * slider for you — had one route to "the player changed this", and it was
3629
+ * `_activate`, which is internal and takes a direction rather than a value.
3630
+ */
3631
+ choose(value) {
3632
+ const next = Math.min(this.max, Math.max(this.min, value));
3927
3633
  if (next === this.value) return;
3928
3634
  this.value = next;
3929
3635
  this.last = next;
@@ -3935,7 +3641,7 @@ var UiSlider = class extends HudWidgetBase {
3935
3641
  last = NaN;
3936
3642
  _build() {
3937
3643
  const wrap = document.createElement("div");
3938
- 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;";
3644
+ wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
3939
3645
  const text = document.createElement("span");
3940
3646
  text.textContent = this._labelText();
3941
3647
  const input = document.createElement("input");
@@ -3944,7 +3650,7 @@ var UiSlider = class extends HudWidgetBase {
3944
3650
  input.max = String(this.max);
3945
3651
  input.step = String(this.step);
3946
3652
  input.value = String(this.value);
3947
- input.style.cssText = `width:${px(this.width)};accent-color:${this.color};`;
3653
+ input.style.cssText = `width:${px(this.width)};accent-color:${this.color};${FITS}`;
3948
3654
  input.addEventListener("input", () => {
3949
3655
  this.value = Number(input.value);
3950
3656
  this.last = this.value;
@@ -3981,18 +3687,21 @@ var UiToggle = class extends HudWidgetBase {
3981
3687
  focusable = true;
3982
3688
  /** Confirm flips it; left/right set it explicitly, which reads better on a pad. */
3983
3689
  _activate(dir) {
3984
- const next = dir === 0 ? !this.value : dir > 0;
3985
- if (next === this.value) return;
3986
- this.value = next;
3987
- this.last = next;
3988
- if (this.input) this.input.checked = next;
3989
- this.emit("changed", next);
3690
+ this.choose(dir === 0 ? !this.value : dir > 0);
3691
+ }
3692
+ /** Flip it as a PERSON would: set the value and say so. See `UiSlider.choose`. */
3693
+ choose(value) {
3694
+ if (value === this.value) return;
3695
+ this.value = value;
3696
+ this.last = value;
3697
+ if (this.input) this.input.checked = value;
3698
+ this.emit("changed", value);
3990
3699
  }
3991
3700
  input = null;
3992
3701
  last = null;
3993
3702
  _build() {
3994
3703
  const wrap = document.createElement("label");
3995
- 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;";
3704
+ 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;`;
3996
3705
  const input = document.createElement("input");
3997
3706
  input.type = "checkbox";
3998
3707
  input.checked = this.value;
@@ -4040,16 +3749,53 @@ var UiSelect = class extends HudWidgetBase {
4040
3749
  options = "";
4041
3750
  value = "";
4042
3751
  focusable = true;
3752
+ /** The options, as the list of values it actually offers. */
3753
+ choices() {
3754
+ return this.options.split(",").map((o) => o.trim()).filter(Boolean);
3755
+ }
3756
+ /**
3757
+ * A value the list cannot show is a setting the game and the screen disagree
3758
+ * about.
3759
+ *
3760
+ * The browser cannot select an option that does not exist, so it shows the
3761
+ * FIRST one — while `node.value` keeps answering with what it was given, and
3762
+ * `changed` only fires when a person moves it, so nothing ever reconciles
3763
+ * them. That is the shape a saved setting has after a patch removes a quality
3764
+ * tier, and it is silent for the rest of the session.
3765
+ *
3766
+ * A hard error at LOAD, like every other enum in this engine. An empty value
3767
+ * is "nothing chosen yet", which is a choice.
3768
+ */
3769
+ static validateJson(node) {
3770
+ const self = node;
3771
+ const choices = self.choices();
3772
+ if (self.value === "" || choices.length === 0 || choices.includes(self.value)) return;
3773
+ 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" });
3774
+ }
4043
3775
  /** Left/right (and confirm) walk the list — a select has no other gesture. */
4044
3776
  _activate(dir) {
4045
- const choices = this.options.split(",").map((o) => o.trim()).filter(Boolean);
3777
+ const choices = this.choices();
4046
3778
  if (choices.length === 0) return;
4047
3779
  const next = choices[(Math.max(0, choices.indexOf(this.value)) + (dir === 0 ? 1 : dir) + choices.length) % choices.length];
4048
- if (!next || next === this.value) return;
4049
- this.value = next;
4050
- this.last = next;
4051
- if (this.select) this.select.value = next;
4052
- this.emit("changed", next);
3780
+ if (next) this.choose(next);
3781
+ }
3782
+ /**
3783
+ * Pick an option as a PERSON would: set the value and say so. See
3784
+ * `UiSlider.choose`.
3785
+ *
3786
+ * An option the list does not offer is refused, for the reason
3787
+ * `validateJson` refuses it in the file: the browser would show the first one
3788
+ * while the node kept answering with what it was given, and nothing would
3789
+ * ever reconcile them.
3790
+ */
3791
+ choose(value) {
3792
+ const choices = this.choices();
3793
+ if (choices.length > 0 && !choices.includes(value)) throw new IncantoError("PROP_TYPE_MISMATCH", `UiSelect '${this.name}': choose('${value}') is not one of its options [${choices.join(", ")}].`, { prop: "value" });
3794
+ if (value === this.value) return;
3795
+ this.value = value;
3796
+ this.last = value;
3797
+ if (this.select) this.select.value = value;
3798
+ this.emit("changed", value);
4053
3799
  }
4054
3800
  select = null;
4055
3801
  labelEl = null;
@@ -4058,7 +3804,7 @@ var UiSelect = class extends HudWidgetBase {
4058
3804
  last = "\0";
4059
3805
  _build() {
4060
3806
  const wrap = document.createElement("label");
4061
- 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;";
3807
+ wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
4062
3808
  const text = document.createElement("span");
4063
3809
  this.labelEl = text;
4064
3810
  const select = document.createElement("select");
@@ -4102,6 +3848,25 @@ var UiSelect = class extends HudWidgetBase {
4102
3848
  if (this.value) select.value = this.value;
4103
3849
  this.last = this.value;
4104
3850
  }
3851
+ /** What was already reported, so a stuck value is said once and not per frame. */
3852
+ complainedAbout = "\0";
3853
+ /**
3854
+ * The runtime half of `validateJson`.
3855
+ *
3856
+ * A behaviour may legitimately write the value first and widen `options`
3857
+ * after — so this reports rather than refuses, and goes quiet again when the
3858
+ * list catches up.
3859
+ */
3860
+ reportMismatch() {
3861
+ const choices = this.choices();
3862
+ if (!(this.value !== "" && choices.length > 0 && !choices.includes(this.value))) {
3863
+ this.complainedAbout = "\0";
3864
+ return;
3865
+ }
3866
+ if (this.complainedAbout === this.value) return;
3867
+ this.complainedAbout = this.value;
3868
+ 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.`);
3869
+ }
4105
3870
  _sync() {
4106
3871
  const label = this._t(this.label);
4107
3872
  if (label !== this.lastLabel && this.labelEl) {
@@ -4114,6 +3879,9 @@ var UiSelect = class extends HudWidgetBase {
4114
3879
  this.select.value = this.value;
4115
3880
  }
4116
3881
  }
3882
+ _pull() {
3883
+ this.reportMismatch();
3884
+ }
4117
3885
  };
4118
3886
  /** Endonyms: `ko` must read as the Korean word to someone who cannot read `Korean`. */
4119
3887
  const ENDONYMS = {
@@ -4159,21 +3927,19 @@ var UiLanguageSelect = class extends UiSelect {
4159
3927
  label = "Language";
4160
3928
  /** Last locale we PUSHED into `value`, to tell an engine change from a player one. */
4161
3929
  lastLocale = "";
4162
- _sync() {
3930
+ _pull() {
4163
3931
  const locale = this.tree?.engine?.locale;
4164
- if (locale) {
4165
- const available = locale.available();
4166
- const options = available.join(",");
4167
- if (options !== this.options) this.options = options;
4168
- if (locale.locale !== this.lastLocale) {
4169
- this.lastLocale = locale.locale;
4170
- this.value = locale.locale;
4171
- } else if (this.value !== locale.locale && available.includes(this.value)) {
4172
- locale.locale = this.value;
4173
- this.lastLocale = this.value;
4174
- }
3932
+ if (!locale) return;
3933
+ const available = locale.available();
3934
+ const options = available.join(",");
3935
+ if (options !== this.options) this.options = options;
3936
+ if (locale.locale !== this.lastLocale) {
3937
+ this.lastLocale = locale.locale;
3938
+ this.value = locale.locale;
3939
+ } else if (this.value !== locale.locale && available.includes(this.value)) {
3940
+ locale.locale = this.value;
3941
+ this.lastLocale = this.value;
4175
3942
  }
4176
- super._sync();
4177
3943
  }
4178
3944
  _optionLabel(value) {
4179
3945
  return ENDONYMS[value] ?? value;
@@ -4204,18 +3970,19 @@ var UiSettingSelect = class extends UiSelect {
4204
3970
  * `settings.quality.high` is worse than one reading `High`. English is the
4205
3971
  * base language and the fallback, always.
4206
3972
  */
4207
- _sync() {
3973
+ _pull() {
4208
3974
  const engine = this._engine();
4209
- if (engine) {
4210
- const stored = this._read(engine);
4211
- if (stored !== this.lastPushed) {
4212
- this.lastPushed = stored;
4213
- this.value = stored;
4214
- } else if (this.value !== stored) {
4215
- this._write(engine, this.value);
4216
- this.lastPushed = this._read(engine);
4217
- }
3975
+ if (!engine) return;
3976
+ const stored = this._read(engine);
3977
+ if (stored !== this.lastPushed) {
3978
+ this.lastPushed = stored;
3979
+ this.value = stored;
3980
+ } else if (this.value !== stored) {
3981
+ this._write(engine, this.value);
3982
+ this.lastPushed = this._read(engine);
4218
3983
  }
3984
+ }
3985
+ _sync() {
4219
3986
  super._sync();
4220
3987
  }
4221
3988
  };
@@ -4378,19 +4145,17 @@ var UiVolumeSlider = class extends UiSlider {
4378
4145
  const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
4379
4146
  return this._tOr(`settings.volume.${this.bus}`, english);
4380
4147
  }
4381
- _sync() {
4148
+ _pull() {
4382
4149
  const audio = this.tree?.engine?.audio;
4383
- if (audio) {
4384
- const stored = audio[this.bus];
4385
- if (stored !== this.lastPushed) {
4386
- this.lastPushed = stored;
4387
- this.value = stored;
4388
- } else if (this.value !== stored) {
4389
- audio[this.bus] = this.value;
4390
- this.lastPushed = audio[this.bus];
4391
- }
4150
+ if (!audio) return;
4151
+ const stored = audio[this.bus];
4152
+ if (stored !== this.lastPushed) {
4153
+ this.lastPushed = stored;
4154
+ this.value = stored;
4155
+ } else if (this.value !== stored) {
4156
+ audio[this.bus] = this.value;
4157
+ this.lastPushed = audio[this.bus];
4392
4158
  }
4393
- super._sync();
4394
4159
  }
4395
4160
  };
4396
4161
  /**
@@ -4413,18 +4178,16 @@ var UiMuteToggle = class extends UiToggle {
4413
4178
  _labelText() {
4414
4179
  return this.label ? this._t(this.label) : this._tOr("settings.mute", "Mute");
4415
4180
  }
4416
- _sync() {
4181
+ _pull() {
4417
4182
  const audio = this.tree?.engine?.audio;
4418
- if (audio) {
4419
- if (audio.muted !== this.lastPushed) {
4420
- this.lastPushed = audio.muted;
4421
- this.value = audio.muted;
4422
- } else if (this.value !== audio.muted) {
4423
- audio.muted = this.value;
4424
- this.lastPushed = audio.muted;
4425
- }
4183
+ if (!audio) return;
4184
+ if (audio.muted !== this.lastPushed) {
4185
+ this.lastPushed = audio.muted;
4186
+ this.value = audio.muted;
4187
+ } else if (this.value !== audio.muted) {
4188
+ audio.muted = this.value;
4189
+ this.lastPushed = audio.muted;
4426
4190
  }
4427
- super._sync();
4428
4191
  }
4429
4192
  };
4430
4193
  //#endregion
@@ -4458,4 +4221,4 @@ function registerCoreNodes() {
4458
4221
  registerNode(UiMuteToggle);
4459
4222
  }
4460
4223
  //#endregion
4461
- 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 };
4224
+ 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 };