incanto 0.52.0 → 0.54.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 (67) hide show
  1. package/bin/incanto-check.mjs +56 -1
  2. package/dist/2d.d.ts +95 -4
  3. package/dist/2d.js +3 -3
  4. package/dist/3d.d.ts +111 -6
  5. package/dist/3d.js +10 -6
  6. package/dist/{behavior-uPEuZrUB.d.ts → behavior-CyQoSu4n.d.ts} +212 -10
  7. package/dist/{create-game-B_e9hJf7.js → create-game-BRt6XKmP.js} +31 -12
  8. package/dist/{create-game-CGnoypjL.js → create-game-Czzp6ZuE.js} +26 -14
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/debug.js +1 -1
  11. package/dist/{duplicate-B-OtSRFL.js → duplicate-MNLMAcbz.js} +1 -1
  12. package/dist/editor.js +14 -0
  13. package/dist/{environment-presets-DSZwsKPs.js → environment-presets-BQ_QsIBY.js} +222 -11
  14. package/dist/{gameplay-B6jqvYeM.js → gameplay-CZ2yq37J.js} +32 -23
  15. package/dist/gameplay.d.ts +8 -3
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +178 -6
  18. package/dist/index.js +9 -9
  19. package/dist/{loader-B-Gft32x.js → loader-BTkHYrQn.js} +54 -18
  20. package/dist/{loader-B9iTqs27.d.ts → loader-DhI1jFW_.d.ts} +1 -1
  21. package/dist/{log-report-lxrQY9cH.js → log-report-CPFm4OXf.js} +0 -0
  22. package/dist/net.d.ts +2 -2
  23. package/dist/net.js +1 -1
  24. package/dist/{particle-sim-BzJ1yxoE.d.ts → particle-sim-C5OfBbmU.d.ts} +11 -0
  25. package/dist/{pathfinding-CAR9DjQQ.d.ts → pathfinding-CXGCpRQe.d.ts} +24 -1
  26. package/dist/{physics-2d-Dns-oZlE.js → physics-2d-CfWAggJ1.js} +2 -2
  27. package/dist/{physics-3d-BhI0ehpe.js → physics-3d-Bes-GIuR.js} +3 -3
  28. package/dist/react.d.ts +1 -1
  29. package/dist/react.js +1 -1
  30. package/dist/{register-Trx7WHnD.js → register-en63AEZO.js} +163 -4
  31. package/dist/{register-DVwlnZAZ.js → register-p48lHE2o.js} +433 -36
  32. package/dist/{replay-BU1CCM15.d.ts → replay-ePMz26jw.d.ts} +1 -1
  33. package/dist/{replay-BicPOMX0.js → replay-t1pP0gQg.js} +2 -2
  34. package/dist/{split-screen-paxkQs_q.d.ts → split-screen-BsdOHbzP.d.ts} +1 -1
  35. package/dist/{split-screen-CZ9ccBBQ.js → split-screen-CSb_uZ6W.js} +2 -2
  36. package/dist/{sprite-animation-D_p28jwU.js → sprite-animation-7qvUxF6Z.js} +19 -0
  37. package/dist/{src-5gbZO47I.js → src-vNPQeQ1W.js} +1 -1
  38. package/dist/{teardown-ks3d5W9n.js → teardown-C7uVSJvx.js} +30 -1
  39. package/dist/{test-DAojuFdb.js → test-Tnf1nQl3.js} +25 -13
  40. package/dist/test.d.ts +4 -4
  41. package/dist/test.js +2 -2
  42. package/dist/{touch-BoNg_MnF.js → touch-BnMyy9tr.js} +4 -4
  43. package/dist/vite.js +2 -2
  44. package/editor/assets/{agent8-CCvckvbw.js → agent8-BWaW-D85.js} +1 -1
  45. package/editor/assets/{debug-CLNCOnbc.js → debug-DLF8rUtr.js} +2 -2
  46. package/editor/assets/{index-Cb5Brupb.js → index-BYfiwbQx.js} +91 -91
  47. package/editor/index.html +1 -1
  48. package/package.json +1 -1
  49. package/schemas/scene.schema.json +221 -0
  50. package/skills/incanto-assets.md +45 -2
  51. package/skills/incanto-audio.md +97 -10
  52. package/skills/incanto-building-2d-games.md +55 -3
  53. package/skills/incanto-building-3d-games.md +3 -1
  54. package/skills/incanto-environment.md +3 -1
  55. package/skills/incanto-gameplay-behaviors.md +51 -4
  56. package/skills/incanto-hud.md +7 -0
  57. package/skills/incanto-node-reference.md +36 -0
  58. package/skills/incanto-performance.md +47 -0
  59. package/skills/incanto-physics-and-input.md +38 -0
  60. package/skills/incanto-verifying-your-game.md +50 -1
  61. package/skills/incanto-web-integration.md +1 -0
  62. package/templates-app/beacon-isle-3d/index.html +3 -3
  63. package/templates-app/beacon-isle-3d/package.json +1 -1
  64. package/templates-app/tps-3d/index.html +3 -3
  65. package/templates-app/tps-3d/package.json +1 -1
  66. package/templates-app/village-quest-3d/index.html +3 -3
  67. package/templates-app/village-quest-3d/package.json +1 -1
@@ -1,25 +1,42 @@
1
- import { C as diagnose, M as Signal, S as InputMap, f as Node, g as restoreBehaviors, h as captureBehaviors, v as createSaveStore, x as resolveOrderGroups, y as ORDER_GROUP_BASE } from "./loader-B-Gft32x.js";
1
+ import { C as diagnose, M as Signal, S as InputMap, f as Node, g as restoreBehaviors, h as captureBehaviors, v as createSaveStore, x as resolveOrderGroups, y as ORDER_GROUP_BASE } from "./loader-BTkHYrQn.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
3
  import { t as Rng } from "./rng-DP-SR7eg.js";
4
4
  import { l as registerNode } from "./registry-C7u42TID.js";
5
5
  //#region src/core/audio/buses.ts
6
- /**
7
- * Global volume state — `master` × `sfx`/`music` gain (0..1) plus a `muted`
8
- * flag. PURE state (no WebAudio): games and AI set it to control overall
9
- * loudness/mute; the renderer reads `effectiveVolume()` when it actually plays
10
- * a sound. Lives on the engine as `engine.audio` (instance-scoped, no
11
- * singletons). The WebAudio wiring is in the adapter; this just holds the
12
- * numbers so they're fully testable in `node`.
13
- *
14
- * ```ts
15
- * engine.audio.master = 0.8; // dim everything
16
- * engine.audio.music = 0.4; // quieter background music
17
- * engine.audio.muted = true; // mute toggle
18
- * ```
19
- */
6
+ const AUDIO_LOG_LIMIT = 200;
20
7
  var AudioBuses = class {
21
8
  /** Fires whenever any value changes (drives live re-gain of playing sounds). */
22
9
  changed = new Signal();
10
+ /**
11
+ * What has sounded, most recent last.
12
+ *
13
+ * Audio is a no-op headless, which left "did the coin sound actually fire
14
+ * when the coin was collected?" unanswerable — the audio skill's own advice
15
+ * was to *"assert `play()` doesn't throw and check your gameplay logic
16
+ * instead"*, which checks something else. The CALL still happens with no
17
+ * backend, so recording it answers the wiring question everywhere: in the
18
+ * verify VM, in a playtest, in a test.
19
+ *
20
+ * Bounded (200) — a game running for an hour must not grow a log.
21
+ */
22
+ events = [];
23
+ /** @internal Called by the players; games read `recent()`. */
24
+ record(event) {
25
+ this.events.push(event);
26
+ if (this.events.length > AUDIO_LOG_LIMIT) this.events.shift();
27
+ }
28
+ /** The last `limit` sounds, oldest first. */
29
+ recent(limit = AUDIO_LOG_LIMIT) {
30
+ return this.events.slice(Math.max(0, this.events.length - limit));
31
+ }
32
+ /** How many times a preset/clip/track sounded — the assertion you want. */
33
+ countOf(name) {
34
+ return this.events.reduce((n, e) => e.name === name ? n + 1 : n, 0);
35
+ }
36
+ /** Forget what has sounded so far (between scenes, or between assertions). */
37
+ clearLog() {
38
+ this.events.length = 0;
39
+ }
23
40
  _master = 1;
24
41
  _sfx = 1;
25
42
  _music = 1;
@@ -191,6 +208,24 @@ var WebAudioMusicBackend = class {
191
208
  const ctx = this.ensure();
192
209
  if (ctx && ctx.state === "suspended") ctx.resume();
193
210
  }
211
+ dispose() {
212
+ this.ctx?.close?.();
213
+ this.ctx = null;
214
+ this.resolved = false;
215
+ }
216
+ };
217
+ //#endregion
218
+ //#region src/core/audio/music-manager.ts
219
+ /** Mints silent tracks so the state machine runs with no audio backend. */
220
+ const SILENT_BACKEND = {
221
+ createTrack: (src) => ({
222
+ src,
223
+ setGain() {},
224
+ setLoop() {},
225
+ play() {},
226
+ stop() {}
227
+ }),
228
+ unlock() {}
194
229
  };
195
230
  /**
196
231
  * Single-track background-music manager: plays ONE music track, crossfading to
@@ -232,13 +267,22 @@ var MusicManager = class {
232
267
  get volume() {
233
268
  return this._volume;
234
269
  }
270
+ /**
271
+ * The backend, or an INERT one that mints silent tracks.
272
+ *
273
+ * Never null, on purpose: the manager used to return before touching its
274
+ * state when there was no backend, so `current` stayed null forever in every
275
+ * headless run — including the verify VM, which is the only place a game is
276
+ * checked automatically. "Did the boss music start?" had no answer anywhere.
277
+ * With an inert backend the state machine runs identically and nothing plays.
278
+ */
235
279
  ensure() {
236
280
  if (!this.resolved) {
237
281
  const b = new WebAudioMusicBackend();
238
282
  this.backend = b && b.available === false ? null : b;
239
283
  this.resolved = true;
240
284
  }
241
- return this.backend;
285
+ return this.backend ?? SILENT_BACKEND;
242
286
  }
243
287
  /** Resume a gesture-suspended context + (re)start any pending track. */
244
288
  unlock() {
@@ -247,8 +291,14 @@ var MusicManager = class {
247
291
  }
248
292
  /** Play a single track (replacing any current one immediately, no fade). */
249
293
  play(src, opts = {}) {
294
+ this.buses.record({
295
+ kind: "music",
296
+ name: src,
297
+ from: "engine.music",
298
+ bus: this._bus,
299
+ at: 0
300
+ });
250
301
  const backend = this.ensure();
251
- if (!backend) return;
252
302
  this.killOutgoing();
253
303
  this.active?.track.stop();
254
304
  this._bus = opts.bus ?? "music";
@@ -270,8 +320,14 @@ var MusicManager = class {
270
320
  * it's a fade-in. Crossfading to the already-current src is a no-op.
271
321
  */
272
322
  crossfadeTo(src, seconds = 2) {
323
+ this.buses.record({
324
+ kind: "music",
325
+ name: src,
326
+ from: "engine.music",
327
+ bus: this._bus,
328
+ at: 0
329
+ });
273
330
  const backend = this.ensure();
274
- if (!backend) return;
275
331
  if (this.active && this.active.track.src === src && this.active.phase !== "fadeOut") return;
276
332
  const secs = Math.max(0, seconds);
277
333
  if (!this.active) {
@@ -319,6 +375,18 @@ var MusicManager = class {
319
375
  };
320
376
  this.applyGains();
321
377
  }
378
+ /**
379
+ * Stop instantly and release the backend — music must not outlive the game.
380
+ *
381
+ * `dispose()` is the one call that tears an SPA-mounted game down, and it used
382
+ * to leave the track playing with the engine that owned it gone.
383
+ */
384
+ dispose() {
385
+ this.stop(0);
386
+ this.backend?.dispose?.();
387
+ this.backend = null;
388
+ this.resolved = false;
389
+ }
322
390
  /** Set the per-manager volume (re-applied to live tracks on the next tick). */
323
391
  setVolume(v) {
324
392
  this._volume = clamp01$1(v);
@@ -853,6 +921,19 @@ var SfxEngine = class {
853
921
  ctx = null;
854
922
  resolved = false;
855
923
  pcmCache = /* @__PURE__ */ new Map();
924
+ /**
925
+ * The engine's buses, when this SfxEngine belongs to one — so a continuous
926
+ * voice lands in the same audio record as everything else. A record that
927
+ * covered only SOME sounds would answer "did the engine hum start?" with a
928
+ * confident no.
929
+ */
930
+ buses = null;
931
+ /** Live continuous voices, so teardown can silence the ones nobody stopped. */
932
+ voices = /* @__PURE__ */ new Set();
933
+ /** @internal Engine wiring. */
934
+ _setBuses(buses) {
935
+ this.buses = buses;
936
+ }
856
937
  /** True once a backend is present (lazily created on first use). */
857
938
  get available() {
858
939
  return this.ensure() !== null;
@@ -870,10 +951,45 @@ var SfxEngine = class {
870
951
  * done. Headless returns an inert handle (same call sites, no ifs).
871
952
  */
872
953
  startVoice(preset, gain = 1) {
954
+ this.buses?.record({
955
+ kind: "voice",
956
+ name: preset,
957
+ from: "engine.sfx",
958
+ bus: "sfx",
959
+ at: 0
960
+ });
873
961
  const ctx = this.ensure();
874
962
  if (!ctx) return INERT_VOICE;
875
963
  if (ctx.state === "suspended") ctx.resume();
876
- return createVoice(ctx, preset, gain);
964
+ const voice = createVoice(ctx, preset, gain);
965
+ for (const v of this.voices) if (v.stopped) this.voices.delete(v);
966
+ this.voices.add(voice);
967
+ return voice;
968
+ }
969
+ /** How many continuous voices are still running (teardown/leak checks). */
970
+ get liveVoiceCount() {
971
+ let n = 0;
972
+ for (const v of this.voices) if (!v.stopped) n++;
973
+ return n;
974
+ }
975
+ /** Silence every continuous voice — the hum must not outlive the game. */
976
+ stopAll(seconds = 0) {
977
+ for (const v of [...this.voices]) v.stop(seconds);
978
+ this.voices.clear();
979
+ }
980
+ /**
981
+ * Silence everything and release the AudioContext.
982
+ *
983
+ * A browser allows only a handful of contexts per page, so an SPA that mounts
984
+ * the game a few times must hand this one back — `dispose()` is documented as
985
+ * the call that tears everything down.
986
+ */
987
+ dispose() {
988
+ this.stopAll();
989
+ this.ctx?.close?.();
990
+ this.ctx = null;
991
+ this.resolved = false;
992
+ this.pcmCache.clear();
877
993
  }
878
994
  /** Resume a gesture-suspended context (wired to the first user gesture). */
879
995
  unlock() {
@@ -1040,6 +1156,33 @@ function suggestLocale(available, languages) {
1040
1156
  return "en";
1041
1157
  }
1042
1158
  //#endregion
1159
+ //#region src/core/effects-log.ts
1160
+ const EFFECT_LOG_LIMIT = 200;
1161
+ var EffectLog = class {
1162
+ events = [];
1163
+ /** @internal Called by the effects themselves; games read `recent()`. */
1164
+ record(event) {
1165
+ this.events.push(event);
1166
+ if (this.events.length > EFFECT_LOG_LIMIT) this.events.shift();
1167
+ }
1168
+ /** The last `limit` effects, oldest first. */
1169
+ recent(limit = EFFECT_LOG_LIMIT) {
1170
+ return this.events.slice(Math.max(0, this.events.length - limit));
1171
+ }
1172
+ /** How many times an effect with this name fired (`explosion`, `#ff0000`). */
1173
+ countOf(name) {
1174
+ return this.events.reduce((n, e) => e.name === name ? n + 1 : n, 0);
1175
+ }
1176
+ /** How many effects this node fired — the question a wiring check has. */
1177
+ countFrom(path) {
1178
+ return this.events.reduce((n, e) => e.from === path ? n + 1 : n, 0);
1179
+ }
1180
+ /** Forget what has fired so far (between scenes, or between assertions). */
1181
+ clearLog() {
1182
+ this.events.length = 0;
1183
+ }
1184
+ };
1185
+ //#endregion
1043
1186
  //#region src/core/frame-stats.ts
1044
1187
  /**
1045
1188
  * Rolling window of REAL frame timestamps — the math behind `engine.stats()`
@@ -1134,6 +1277,57 @@ var LogManager = class {
1134
1277
  }
1135
1278
  };
1136
1279
  //#endregion
1280
+ //#region src/core/phase-timing.ts
1281
+ const EMPTY = {
1282
+ fixedMs: 0,
1283
+ updateMs: 0,
1284
+ renderMs: 0,
1285
+ otherMs: 0
1286
+ };
1287
+ var PhaseTimings = class {
1288
+ capacity;
1289
+ fixed = [];
1290
+ update = [];
1291
+ render = [];
1292
+ head = 0;
1293
+ constructor(capacity = 60) {
1294
+ this.capacity = capacity;
1295
+ }
1296
+ /** Bank one frame's engine-side work. */
1297
+ push(fixedMs, updateMs, renderMs) {
1298
+ const i = this.head % this.capacity;
1299
+ this.fixed[i] = fixedMs;
1300
+ this.update[i] = updateMs;
1301
+ this.render[i] = renderMs;
1302
+ this.head += 1;
1303
+ }
1304
+ /** Means over the window; `frameMs` (0 headless) fills in `otherMs`. */
1305
+ read(frameMs) {
1306
+ const n = Math.min(this.head, this.capacity);
1307
+ if (n === 0) return { ...EMPTY };
1308
+ const fixedMs = mean(this.fixed, n);
1309
+ const updateMs = mean(this.update, n);
1310
+ const renderMs = mean(this.render, n);
1311
+ return {
1312
+ fixedMs,
1313
+ updateMs,
1314
+ renderMs,
1315
+ otherMs: Math.max(0, frameMs - fixedMs - updateMs - renderMs)
1316
+ };
1317
+ }
1318
+ clear() {
1319
+ this.fixed.length = 0;
1320
+ this.update.length = 0;
1321
+ this.render.length = 0;
1322
+ this.head = 0;
1323
+ }
1324
+ };
1325
+ function mean(values, n) {
1326
+ let total = 0;
1327
+ for (let i = 0; i < n; i++) total += values[i] ?? 0;
1328
+ return total / n;
1329
+ }
1330
+ //#endregion
1137
1331
  //#region src/core/settings.ts
1138
1332
  const DEFAULTS = {
1139
1333
  master: 1,
@@ -1432,6 +1626,12 @@ var Engine = class {
1432
1626
  * routes its volume through this; games set it for global volume/mute. */
1433
1627
  audio = new AudioBuses();
1434
1628
  /**
1629
+ * What SHOWED — the visual half of the audio record. Particle bursts, camera
1630
+ * shake, screen flash and freeze frames land here, so "did the explosion
1631
+ * fire?" is answerable in a run with no renderer.
1632
+ */
1633
+ effects = new EffectLog();
1634
+ /**
1435
1635
  * Persisted player settings — volume, quality, sensitivity.
1436
1636
  *
1437
1637
  * `createSaveStore` was good and had zero callers, so every game's volume
@@ -1474,6 +1674,10 @@ var Engine = class {
1474
1674
  accumulator = 0;
1475
1675
  disposeScheduler = null;
1476
1676
  frameStats = new FrameStatsRing();
1677
+ /** Where each frame's time went — the breakdown `frameMs` alone cannot give. */
1678
+ phaseTimings = new PhaseTimings();
1679
+ /** This frame's render cost, as reported by whoever renders. */
1680
+ renderMs = 0;
1477
1681
  /**
1478
1682
  * Game-time multiplier: 1 = realtime, 0.5 = slow motion, 0 = frozen
1479
1683
  * (hit-stop / pause). Scales BOTH variable and fixed updates — physics,
@@ -1508,6 +1712,7 @@ var Engine = class {
1508
1712
  this.scheduler = opts.scheduler ?? rafScheduler;
1509
1713
  this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
1510
1714
  this.maxFps = opts.maxFps ?? 0;
1715
+ this.sfx._setBuses(this.audio);
1511
1716
  }
1512
1717
  get scene() {
1513
1718
  return this._scene;
@@ -1664,12 +1869,24 @@ var Engine = class {
1664
1869
  this.lastFrameError = null;
1665
1870
  return nodes.length;
1666
1871
  }
1872
+ /**
1873
+ * How long the last render took, in ms — called by whoever renders.
1874
+ *
1875
+ * The engine does not render, so this is the one slice it cannot measure for
1876
+ * itself; without it the cost lands in `otherMs` beside the GC and the two
1877
+ * are indistinguishable, which is the confusion this whole breakdown exists
1878
+ * to end.
1879
+ */
1880
+ noteRenderMs(ms) {
1881
+ this.renderMs = ms;
1882
+ }
1667
1883
  stop() {
1668
1884
  this.disposeScheduler?.();
1669
1885
  this.disposeScheduler = null;
1670
1886
  this.lastMs = null;
1671
1887
  this.accumulator = 0;
1672
1888
  this.frameStats.clear();
1889
+ this.phaseTimings.clear();
1673
1890
  }
1674
1891
  /**
1675
1892
  * Live performance counters, queryable at ANY time. fps/frameMs come from a
@@ -1683,7 +1900,8 @@ var Engine = class {
1683
1900
  frameMs: this.frameStats.frameMs(),
1684
1901
  nodes: this._scene?.tree.root ? countNodes(this._scene.tree.root) : 0,
1685
1902
  running: this.disposeScheduler !== null,
1686
- errors: this.errorCount
1903
+ errors: this.errorCount,
1904
+ phases: this.phaseTimings.read(this.frameStats.frameMs())
1687
1905
  };
1688
1906
  }
1689
1907
  /**
@@ -1704,6 +1922,8 @@ var Engine = class {
1704
1922
  */
1705
1923
  dispose() {
1706
1924
  this.stop();
1925
+ this.music.dispose();
1926
+ this.sfx.dispose();
1707
1927
  if (this._scene) {
1708
1928
  this._scene.tree._setEngine(null);
1709
1929
  this._scene.root.free();
@@ -1715,21 +1935,31 @@ var Engine = class {
1715
1935
  }
1716
1936
  /**
1717
1937
  * Advance exactly ONE fixed step and one variable update (both dt = the
1718
- * fixed step), bypassing the wall-clock accumulator entirely. Drift-free
1719
- * by construction — the unit of time for headless harnesses (incanto/test
1720
- * runScript): every input edge is visible to BOTH fixedUpdate and update
1721
- * exactly once.
1938
+ * fixed step scaled by `timeScale`), bypassing the wall-clock accumulator
1939
+ * entirely. Drift-free by construction — the unit of time for headless
1940
+ * harnesses (incanto/test runScript): every input edge is visible to BOTH
1941
+ * fixedUpdate and update exactly once.
1942
+ *
1943
+ * `ignorePause: true` steps a frame at full scale even while the game is
1944
+ * paused — the debug overlay's single-step button, and nothing else.
1722
1945
  */
1723
- step() {
1946
+ step(opts = {}) {
1724
1947
  const scene = this._scene;
1725
1948
  if (!scene) return;
1726
- this._time += this.fixedStep;
1949
+ const dt = this.fixedStep * (opts.ignorePause ? 1 : this.timeScale);
1950
+ this._time += dt;
1727
1951
  this._unscaledTime += this.fixedStep;
1728
- scene.tree.fixedUpdate(this.fixedStep);
1729
- this.fixedUpdated.emit(this.fixedStep);
1730
- scene.tree.update(this.fixedStep);
1731
- this.music.tick(this.fixedStep);
1732
- this.updated.emit(this.fixedStep);
1952
+ const startedFixed = performance.now();
1953
+ scene.tree.fixedUpdate(dt);
1954
+ this.fixedUpdated.emit(dt);
1955
+ const startedUpdate = performance.now();
1956
+ scene.tree.update(dt);
1957
+ this.music.tick(dt);
1958
+ this.updated.emit(dt);
1959
+ const ended = performance.now();
1960
+ const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
1961
+ this.phaseTimings.push(startedUpdate - startedFixed, updateMs, this.renderMs);
1962
+ this.renderMs = 0;
1733
1963
  this.input.endFrame();
1734
1964
  }
1735
1965
  /** Advance the loop manually. First call after (re)start only primes the clock. */
@@ -1748,6 +1978,7 @@ var Engine = class {
1748
1978
  if (!scene) return;
1749
1979
  this.accumulator += dt;
1750
1980
  let steps = 0;
1981
+ const startedFixed = performance.now();
1751
1982
  while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
1752
1983
  scene.tree.fixedUpdate(this.fixedStep);
1753
1984
  this.fixedUpdated.emit(this.fixedStep);
@@ -1755,9 +1986,14 @@ var Engine = class {
1755
1986
  steps += 1;
1756
1987
  }
1757
1988
  if (this.accumulator >= this.fixedStep) this.accumulator %= this.fixedStep;
1989
+ const startedUpdate = performance.now();
1758
1990
  scene.tree.update(dt);
1759
1991
  this.music.tick(dt);
1760
1992
  this.updated.emit(dt);
1993
+ const ended = performance.now();
1994
+ const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
1995
+ this.phaseTimings.push(startedUpdate - startedFixed, updateMs, this.renderMs);
1996
+ this.renderMs = 0;
1761
1997
  this.input.endFrame();
1762
1998
  }
1763
1999
  };
@@ -1859,6 +2095,11 @@ var AudioPlayer = class extends Node {
1859
2095
  }
1860
2096
  /** A play() was blocked by the browser's autoplay policy. */
1861
2097
  pendingGesture = false;
2098
+ /**
2099
+ * Why this player is silent, when the reason is the FILE rather than the
2100
+ * autoplay gate. Read by `game.assetErrors()`.
2101
+ */
2102
+ loadError = null;
1862
2103
  element = null;
1863
2104
  _playing = false;
1864
2105
  autoplayed = false;
@@ -1875,6 +2116,23 @@ var AudioPlayer = class extends Node {
1875
2116
  _setSpatialPose(pose) {
1876
2117
  this._spatialPose = pose;
1877
2118
  }
2119
+ /**
2120
+ * Write this sound into the engine's audio record.
2121
+ *
2122
+ * The CALL happens even where the backend does not (headless, the verify VM),
2123
+ * which is exactly why the record is worth keeping: it answers "did the coin
2124
+ * sound fire when the coin was collected" in places no sound can be heard.
2125
+ */
2126
+ note(kind, name) {
2127
+ const engine = this.tree?.engine;
2128
+ engine?.audio.record({
2129
+ kind,
2130
+ name,
2131
+ from: this.getPath(),
2132
+ bus: this.bus === "music" ? "music" : "sfx",
2133
+ at: engine.time
2134
+ });
2135
+ }
1878
2136
  /** Final gain = engine buses × this volume (1 when not in a tree). */
1879
2137
  gain() {
1880
2138
  const buses = this.tree?.engine?.audio;
@@ -1906,10 +2164,12 @@ var AudioPlayer = class extends Node {
1906
2164
  }
1907
2165
  play() {
1908
2166
  if (this.preset !== "custom") {
2167
+ this.note("preset", this.preset);
1909
2168
  this.playPreset();
1910
2169
  return;
1911
2170
  }
1912
2171
  if (!this.src) return;
2172
+ this.note("src", this.src);
1913
2173
  if (!this.element) {
1914
2174
  this.element = createElement();
1915
2175
  if (!this.element) return;
@@ -1918,6 +2178,12 @@ var AudioPlayer = class extends Node {
1918
2178
  this._playing = false;
1919
2179
  this.emit("finished");
1920
2180
  });
2181
+ this.element.addEventListener("error", () => {
2182
+ this._playing = false;
2183
+ if (this.loadError) return;
2184
+ this.loadError = `could not load ${this.src}`;
2185
+ diagnose(this.tree?.engine ?? null, "error", `[incanto] AudioPlayer '${this.name}' could not load '${this.src}' — it is silent.`);
2186
+ });
1921
2187
  }
1922
2188
  const el = this.element;
1923
2189
  el.src = this.src;
@@ -1925,9 +2191,15 @@ var AudioPlayer = class extends Node {
1925
2191
  el.loop = this.loop;
1926
2192
  this._playing = true;
1927
2193
  this.pendingGesture = false;
1928
- el.play().catch(() => {
2194
+ el.play().catch((error) => {
1929
2195
  this._playing = false;
1930
- this.pendingGesture = true;
2196
+ const name = error?.name ?? "";
2197
+ if (name !== "NotSupportedError") {
2198
+ this.pendingGesture = true;
2199
+ return;
2200
+ }
2201
+ this.loadError = `${name}: ${this.src}`;
2202
+ diagnose(this.tree?.engine ?? null, "error", `[incanto] AudioPlayer '${this.name}' cannot play '${this.src}' (${name}) — it is silent.`);
1931
2203
  });
1932
2204
  }
1933
2205
  /**
@@ -1948,6 +2220,19 @@ var AudioPlayer = class extends Node {
1948
2220
  spatial: this.spatialPlay()
1949
2221
  });
1950
2222
  }
2223
+ /**
2224
+ * A destroyed node goes SILENT.
2225
+ *
2226
+ * Nothing used to stop a sound on teardown, so a level's looping background
2227
+ * clip kept playing over the level after it — the node was freed, so nobody
2228
+ * had a handle to stop it with either. Being MOVED is deliberately not this:
2229
+ * reparenting a node is not destroying it, and a sound that survives the move
2230
+ * is what a scene graph should do.
2231
+ */
2232
+ free() {
2233
+ this.stop();
2234
+ super.free();
2235
+ }
1951
2236
  stop() {
1952
2237
  if (this.element) {
1953
2238
  this.element.pause();
@@ -2805,6 +3090,17 @@ function px(n) {
2805
3090
  return `${n}px`;
2806
3091
  }
2807
3092
  /**
3093
+ * The translated string, or the English one.
3094
+ *
3095
+ * `locale.t` returns the KEY when nothing declares it, and a menu reading
3096
+ * `settings.volume.music` is worse than one reading `Music`. English is the
3097
+ * base language and the fallback, always.
3098
+ */
3099
+ function translated(engine, key, fallback) {
3100
+ const locale = engine?.locale;
3101
+ return locale?.has(key) ? locale.t(key) : fallback;
3102
+ }
3103
+ /**
2808
3104
  * A box that holds other widgets — the thing a menu actually is.
2809
3105
  *
2810
3106
  * Widgets normally mount into their HudLayer's anchor slot, which is why a
@@ -3227,8 +3523,7 @@ var UiSettingSelect = class extends UiSelect {
3227
3523
  * base language and the fallback, always.
3228
3524
  */
3229
3525
  _tOr(key, fallback) {
3230
- const locale = this._engine()?.locale;
3231
- return locale?.has(key) ? locale.t(key) : fallback;
3526
+ return translated(this._engine(), key, fallback);
3232
3527
  }
3233
3528
  _sync() {
3234
3529
  const engine = this._engine();
@@ -3356,6 +3651,106 @@ var UiRenderScaleSelect = class extends UiSettingSelect {
3356
3651
  return Number.isFinite(pct) ? `${pct}%` : value;
3357
3652
  }
3358
3653
  };
3654
+ /** The three buses a player is ever offered a slider for. */
3655
+ const VOLUME_BUSES = [
3656
+ "master",
3657
+ "sfx",
3658
+ "music"
3659
+ ];
3660
+ const VOLUME_LABELS = {
3661
+ master: "Volume",
3662
+ sfx: "Sound",
3663
+ music: "Music"
3664
+ };
3665
+ /**
3666
+ * The volume slider — the settings row every game has.
3667
+ *
3668
+ * ```json
3669
+ * { "name": "Music", "type": "UiVolumeSlider", "props": { "bus": "music" } }
3670
+ * ```
3671
+ *
3672
+ * The graphics controls above became nodes because a game whose whole world is
3673
+ * JSON could not put a menu on screen without a behavior, a signal connection
3674
+ * and the knowledge that `Settings` persists. All of that was just as true of
3675
+ * volume, which is the control a player looks for FIRST and the only one some
3676
+ * games need at all — so an audio menu meant hand-rolled DOM, and hand-rolled
3677
+ * DOM is out of scene JSON, out of the editor, and out of every headless check.
3678
+ *
3679
+ * It writes the bus; the bus persists itself (`bindAudio`, which
3680
+ * `createGame2D/3D` wire), so there is nothing to save.
3681
+ */
3682
+ var UiVolumeSlider = class extends UiSlider {
3683
+ static typeName = "UiVolumeSlider";
3684
+ static props = {
3685
+ ...UiSlider.props,
3686
+ /** Which bus this row owns. `master` dims everything. */
3687
+ bus: {
3688
+ default: "master",
3689
+ options: [...VOLUME_BUSES]
3690
+ },
3691
+ /** Empty takes the bus's own name — a row nobody labelled still reads. */
3692
+ label: { default: "" }
3693
+ };
3694
+ bus = "master";
3695
+ /** Last value we PUSHED in, to tell an engine-side change from a player drag. */
3696
+ lastPushed = null;
3697
+ _build() {
3698
+ if (!this.label) {
3699
+ const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
3700
+ this.label = translated(this.tree?.engine ?? null, `settings.volume.${this.bus}`, english);
3701
+ }
3702
+ return super._build();
3703
+ }
3704
+ _sync() {
3705
+ const audio = this.tree?.engine?.audio;
3706
+ if (audio) {
3707
+ const stored = audio[this.bus];
3708
+ if (stored !== this.lastPushed) {
3709
+ this.lastPushed = stored;
3710
+ this.value = stored;
3711
+ } else if (this.value !== stored) {
3712
+ audio[this.bus] = this.value;
3713
+ this.lastPushed = audio[this.bus];
3714
+ }
3715
+ }
3716
+ super._sync();
3717
+ }
3718
+ };
3719
+ /**
3720
+ * The mute switch.
3721
+ *
3722
+ * ```json
3723
+ * { "name": "Mute", "type": "UiMuteToggle" }
3724
+ * ```
3725
+ *
3726
+ * Checked means silent. Like the slider it writes `engine.audio`, which
3727
+ * persists itself.
3728
+ */
3729
+ var UiMuteToggle = class extends UiToggle {
3730
+ static typeName = "UiMuteToggle";
3731
+ static props = {
3732
+ ...UiToggle.props,
3733
+ label: { default: "" }
3734
+ };
3735
+ lastPushed = null;
3736
+ _build() {
3737
+ if (!this.label) this.label = translated(this.tree?.engine ?? null, "settings.mute", "Mute");
3738
+ return super._build();
3739
+ }
3740
+ _sync() {
3741
+ const audio = this.tree?.engine?.audio;
3742
+ if (audio) {
3743
+ if (audio.muted !== this.lastPushed) {
3744
+ this.lastPushed = audio.muted;
3745
+ this.value = audio.muted;
3746
+ } else if (this.value !== audio.muted) {
3747
+ audio.muted = this.value;
3748
+ this.lastPushed = audio.muted;
3749
+ }
3750
+ }
3751
+ super._sync();
3752
+ }
3753
+ };
3359
3754
  //#endregion
3360
3755
  //#region src/core/register.ts
3361
3756
  /**
@@ -3382,6 +3777,8 @@ function registerCoreNodes() {
3382
3777
  registerNode(UiQualitySelect);
3383
3778
  registerNode(UiFrameCapSelect);
3384
3779
  registerNode(UiRenderScaleSelect);
3780
+ registerNode(UiVolumeSlider);
3781
+ registerNode(UiMuteToggle);
3385
3782
  }
3386
3783
  //#endregion
3387
- export { ROLLOFF_MODELS as A, AudioBuses as B, BASE_LOCALE as C, translationKey as D, suggestLocale as E, synthSfx as F, MusicManager as I, WebAudioMusicBackend as L, spatialPan as M, SFX_PRESETS as N, SfxEngine as O, SFX_PRESET_NAMES as P, crossfadeGains as R, LogManager as S, T_PREFIX as T, qualityCaps as _, UiSlider as a, readDeviceHints as b, UiButton as c, UiBanner as d, UiBar as f, Settings as g, Engine as h, UiSelect as i, spatialGain as j, isAudioContextAvailable as k, UiDialogue as l, AudioPlayer as m, UiImage as n, UiToggle as o, UiText as p, UiPanel as r, Timer as s, registerCoreNodes as t, HudLayer as u, qualityEnvironment as v, Localization as w, suggestQuality as x, qualityRendering as y, fadeGain as z };
3784
+ export { BASE_LOCALE as A, SFX_PRESETS as B, qualityCaps as C, suggestQuality as D, readDeviceHints as E, SfxEngine as F, crossfadeGains as G, synthSfx as H, isAudioContextAvailable as I, fadeGain as K, ROLLOFF_MODELS as L, T_PREFIX as M, suggestLocale as N, LogManager as O, translationKey as P, spatialGain as R, Settings as S, qualityRendering as T, MusicManager as U, SFX_PRESET_NAMES as V, WebAudioMusicBackend as W, UiBanner as _, UiMuteToggle as a, AudioPlayer as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, HudLayer as g, UiDialogue as h, UiLanguageSelect as i, Localization as j, EffectLog as k, UiSelect as l, UiButton 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, UiBar as v, qualityEnvironment as w, Engine as x, UiText as y, spatialPan as z };
@@ -1,4 +1,4 @@
1
- import { b as Engine } from "./behavior-uPEuZrUB.js";
1
+ import { b as Engine } from "./behavior-CyQoSu4n.js";
2
2
  import { s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/core/audit.d.ts