incanto 0.52.0 → 0.53.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 (58) hide show
  1. package/dist/2d.d.ts +51 -3
  2. package/dist/2d.js +3 -3
  3. package/dist/3d.d.ts +9 -3
  4. package/dist/3d.js +4 -4
  5. package/dist/{behavior-uPEuZrUB.d.ts → behavior-Do0Da56m.d.ts} +98 -0
  6. package/dist/{create-game-B_e9hJf7.js → create-game-9D87XaiX.js} +26 -11
  7. package/dist/{create-game-CGnoypjL.js → create-game-CDJ1lVqK.js} +21 -13
  8. package/dist/debug.d.ts +1 -1
  9. package/dist/{duplicate-B-OtSRFL.js → duplicate-MNLMAcbz.js} +1 -1
  10. package/dist/editor.js +14 -0
  11. package/dist/{environment-presets-DSZwsKPs.js → environment-presets-SkGanr2s.js} +2 -2
  12. package/dist/{gameplay-B6jqvYeM.js → gameplay-CRYw_Q-T.js} +2 -2
  13. package/dist/gameplay.d.ts +1 -1
  14. package/dist/gameplay.js +1 -1
  15. package/dist/index.d.ts +177 -5
  16. package/dist/index.js +8 -8
  17. package/dist/{loader-B-Gft32x.js → loader-BTkHYrQn.js} +54 -18
  18. package/dist/{loader-B9iTqs27.d.ts → loader-Cu_7kJDy.d.ts} +1 -1
  19. package/dist/{log-report-lxrQY9cH.js → log-report-CPFm4OXf.js} +0 -0
  20. package/dist/net.d.ts +2 -2
  21. package/dist/net.js +1 -1
  22. package/dist/{pathfinding-CAR9DjQQ.d.ts → pathfinding-HNGFqGUZ.d.ts} +24 -1
  23. package/dist/{physics-2d-Dns-oZlE.js → physics-2d-BhZZ-HAp.js} +2 -2
  24. package/dist/{physics-3d-BhI0ehpe.js → physics-3d-DmyO2oaN.js} +3 -3
  25. package/dist/react.d.ts +1 -1
  26. package/dist/react.js +1 -1
  27. package/dist/{register-DVwlnZAZ.js → register-BTomIiYG.js} +304 -24
  28. package/dist/{register-Trx7WHnD.js → register-DROK2l7J.js} +2 -2
  29. package/dist/{replay-BU1CCM15.d.ts → replay-9Fy6C10F.d.ts} +1 -1
  30. package/dist/{replay-BicPOMX0.js → replay-DYNUL4BU.js} +2 -2
  31. package/dist/{split-screen-CZ9ccBBQ.js → split-screen-CaF9hO7g.js} +2 -2
  32. package/dist/{split-screen-paxkQs_q.d.ts → split-screen-DNmcX1Pz.d.ts} +1 -1
  33. package/dist/{src-5gbZO47I.js → src-C9xyZW7M.js} +1 -1
  34. package/dist/{teardown-ks3d5W9n.js → teardown-Bw2aeyGI.js} +30 -1
  35. package/dist/{test-DAojuFdb.js → test-CJRsciFk.js} +11 -11
  36. package/dist/test.d.ts +4 -4
  37. package/dist/test.js +2 -2
  38. package/dist/{touch-BoNg_MnF.js → touch-BnMyy9tr.js} +4 -4
  39. package/dist/vite.js +2 -2
  40. package/editor/assets/{agent8-CCvckvbw.js → agent8-PlFHzJsh.js} +1 -1
  41. package/editor/assets/{debug-CLNCOnbc.js → debug-CebV7CDW.js} +1 -1
  42. package/editor/assets/{index-Cb5Brupb.js → index-CdbsY31G.js} +91 -91
  43. package/editor/index.html +1 -1
  44. package/package.json +1 -1
  45. package/schemas/scene.schema.json +213 -0
  46. package/skills/incanto-audio.md +97 -10
  47. package/skills/incanto-building-2d-games.md +20 -3
  48. package/skills/incanto-hud.md +7 -0
  49. package/skills/incanto-node-reference.md +34 -0
  50. package/skills/incanto-physics-and-input.md +38 -0
  51. package/skills/incanto-verifying-your-game.md +19 -1
  52. package/skills/incanto-web-integration.md +1 -0
  53. package/templates-app/beacon-isle-3d/index.html +3 -3
  54. package/templates-app/beacon-isle-3d/package.json +1 -1
  55. package/templates-app/tps-3d/index.html +3 -3
  56. package/templates-app/tps-3d/package.json +1 -1
  57. package/templates-app/village-quest-3d/index.html +3 -3
  58. 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() {
@@ -1508,6 +1624,7 @@ var Engine = class {
1508
1624
  this.scheduler = opts.scheduler ?? rafScheduler;
1509
1625
  this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
1510
1626
  this.maxFps = opts.maxFps ?? 0;
1627
+ this.sfx._setBuses(this.audio);
1511
1628
  }
1512
1629
  get scene() {
1513
1630
  return this._scene;
@@ -1704,6 +1821,8 @@ var Engine = class {
1704
1821
  */
1705
1822
  dispose() {
1706
1823
  this.stop();
1824
+ this.music.dispose();
1825
+ this.sfx.dispose();
1707
1826
  if (this._scene) {
1708
1827
  this._scene.tree._setEngine(null);
1709
1828
  this._scene.root.free();
@@ -1859,6 +1978,11 @@ var AudioPlayer = class extends Node {
1859
1978
  }
1860
1979
  /** A play() was blocked by the browser's autoplay policy. */
1861
1980
  pendingGesture = false;
1981
+ /**
1982
+ * Why this player is silent, when the reason is the FILE rather than the
1983
+ * autoplay gate. Read by `game.assetErrors()`.
1984
+ */
1985
+ loadError = null;
1862
1986
  element = null;
1863
1987
  _playing = false;
1864
1988
  autoplayed = false;
@@ -1875,6 +1999,23 @@ var AudioPlayer = class extends Node {
1875
1999
  _setSpatialPose(pose) {
1876
2000
  this._spatialPose = pose;
1877
2001
  }
2002
+ /**
2003
+ * Write this sound into the engine's audio record.
2004
+ *
2005
+ * The CALL happens even where the backend does not (headless, the verify VM),
2006
+ * which is exactly why the record is worth keeping: it answers "did the coin
2007
+ * sound fire when the coin was collected" in places no sound can be heard.
2008
+ */
2009
+ note(kind, name) {
2010
+ const engine = this.tree?.engine;
2011
+ engine?.audio.record({
2012
+ kind,
2013
+ name,
2014
+ from: this.getPath(),
2015
+ bus: this.bus === "music" ? "music" : "sfx",
2016
+ at: engine.time
2017
+ });
2018
+ }
1878
2019
  /** Final gain = engine buses × this volume (1 when not in a tree). */
1879
2020
  gain() {
1880
2021
  const buses = this.tree?.engine?.audio;
@@ -1906,10 +2047,12 @@ var AudioPlayer = class extends Node {
1906
2047
  }
1907
2048
  play() {
1908
2049
  if (this.preset !== "custom") {
2050
+ this.note("preset", this.preset);
1909
2051
  this.playPreset();
1910
2052
  return;
1911
2053
  }
1912
2054
  if (!this.src) return;
2055
+ this.note("src", this.src);
1913
2056
  if (!this.element) {
1914
2057
  this.element = createElement();
1915
2058
  if (!this.element) return;
@@ -1918,6 +2061,12 @@ var AudioPlayer = class extends Node {
1918
2061
  this._playing = false;
1919
2062
  this.emit("finished");
1920
2063
  });
2064
+ this.element.addEventListener("error", () => {
2065
+ this._playing = false;
2066
+ if (this.loadError) return;
2067
+ this.loadError = `could not load ${this.src}`;
2068
+ diagnose(this.tree?.engine ?? null, "error", `[incanto] AudioPlayer '${this.name}' could not load '${this.src}' — it is silent.`);
2069
+ });
1921
2070
  }
1922
2071
  const el = this.element;
1923
2072
  el.src = this.src;
@@ -1925,9 +2074,15 @@ var AudioPlayer = class extends Node {
1925
2074
  el.loop = this.loop;
1926
2075
  this._playing = true;
1927
2076
  this.pendingGesture = false;
1928
- el.play().catch(() => {
2077
+ el.play().catch((error) => {
1929
2078
  this._playing = false;
1930
- this.pendingGesture = true;
2079
+ const name = error?.name ?? "";
2080
+ if (name !== "NotSupportedError") {
2081
+ this.pendingGesture = true;
2082
+ return;
2083
+ }
2084
+ this.loadError = `${name}: ${this.src}`;
2085
+ diagnose(this.tree?.engine ?? null, "error", `[incanto] AudioPlayer '${this.name}' cannot play '${this.src}' (${name}) — it is silent.`);
1931
2086
  });
1932
2087
  }
1933
2088
  /**
@@ -1948,6 +2103,19 @@ var AudioPlayer = class extends Node {
1948
2103
  spatial: this.spatialPlay()
1949
2104
  });
1950
2105
  }
2106
+ /**
2107
+ * A destroyed node goes SILENT.
2108
+ *
2109
+ * Nothing used to stop a sound on teardown, so a level's looping background
2110
+ * clip kept playing over the level after it — the node was freed, so nobody
2111
+ * had a handle to stop it with either. Being MOVED is deliberately not this:
2112
+ * reparenting a node is not destroying it, and a sound that survives the move
2113
+ * is what a scene graph should do.
2114
+ */
2115
+ free() {
2116
+ this.stop();
2117
+ super.free();
2118
+ }
1951
2119
  stop() {
1952
2120
  if (this.element) {
1953
2121
  this.element.pause();
@@ -2805,6 +2973,17 @@ function px(n) {
2805
2973
  return `${n}px`;
2806
2974
  }
2807
2975
  /**
2976
+ * The translated string, or the English one.
2977
+ *
2978
+ * `locale.t` returns the KEY when nothing declares it, and a menu reading
2979
+ * `settings.volume.music` is worse than one reading `Music`. English is the
2980
+ * base language and the fallback, always.
2981
+ */
2982
+ function translated(engine, key, fallback) {
2983
+ const locale = engine?.locale;
2984
+ return locale?.has(key) ? locale.t(key) : fallback;
2985
+ }
2986
+ /**
2808
2987
  * A box that holds other widgets — the thing a menu actually is.
2809
2988
  *
2810
2989
  * Widgets normally mount into their HudLayer's anchor slot, which is why a
@@ -3227,8 +3406,7 @@ var UiSettingSelect = class extends UiSelect {
3227
3406
  * base language and the fallback, always.
3228
3407
  */
3229
3408
  _tOr(key, fallback) {
3230
- const locale = this._engine()?.locale;
3231
- return locale?.has(key) ? locale.t(key) : fallback;
3409
+ return translated(this._engine(), key, fallback);
3232
3410
  }
3233
3411
  _sync() {
3234
3412
  const engine = this._engine();
@@ -3356,6 +3534,106 @@ var UiRenderScaleSelect = class extends UiSettingSelect {
3356
3534
  return Number.isFinite(pct) ? `${pct}%` : value;
3357
3535
  }
3358
3536
  };
3537
+ /** The three buses a player is ever offered a slider for. */
3538
+ const VOLUME_BUSES = [
3539
+ "master",
3540
+ "sfx",
3541
+ "music"
3542
+ ];
3543
+ const VOLUME_LABELS = {
3544
+ master: "Volume",
3545
+ sfx: "Sound",
3546
+ music: "Music"
3547
+ };
3548
+ /**
3549
+ * The volume slider — the settings row every game has.
3550
+ *
3551
+ * ```json
3552
+ * { "name": "Music", "type": "UiVolumeSlider", "props": { "bus": "music" } }
3553
+ * ```
3554
+ *
3555
+ * The graphics controls above became nodes because a game whose whole world is
3556
+ * JSON could not put a menu on screen without a behavior, a signal connection
3557
+ * and the knowledge that `Settings` persists. All of that was just as true of
3558
+ * volume, which is the control a player looks for FIRST and the only one some
3559
+ * games need at all — so an audio menu meant hand-rolled DOM, and hand-rolled
3560
+ * DOM is out of scene JSON, out of the editor, and out of every headless check.
3561
+ *
3562
+ * It writes the bus; the bus persists itself (`bindAudio`, which
3563
+ * `createGame2D/3D` wire), so there is nothing to save.
3564
+ */
3565
+ var UiVolumeSlider = class extends UiSlider {
3566
+ static typeName = "UiVolumeSlider";
3567
+ static props = {
3568
+ ...UiSlider.props,
3569
+ /** Which bus this row owns. `master` dims everything. */
3570
+ bus: {
3571
+ default: "master",
3572
+ options: [...VOLUME_BUSES]
3573
+ },
3574
+ /** Empty takes the bus's own name — a row nobody labelled still reads. */
3575
+ label: { default: "" }
3576
+ };
3577
+ bus = "master";
3578
+ /** Last value we PUSHED in, to tell an engine-side change from a player drag. */
3579
+ lastPushed = null;
3580
+ _build() {
3581
+ if (!this.label) {
3582
+ const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
3583
+ this.label = translated(this.tree?.engine ?? null, `settings.volume.${this.bus}`, english);
3584
+ }
3585
+ return super._build();
3586
+ }
3587
+ _sync() {
3588
+ const audio = this.tree?.engine?.audio;
3589
+ if (audio) {
3590
+ const stored = audio[this.bus];
3591
+ if (stored !== this.lastPushed) {
3592
+ this.lastPushed = stored;
3593
+ this.value = stored;
3594
+ } else if (this.value !== stored) {
3595
+ audio[this.bus] = this.value;
3596
+ this.lastPushed = audio[this.bus];
3597
+ }
3598
+ }
3599
+ super._sync();
3600
+ }
3601
+ };
3602
+ /**
3603
+ * The mute switch.
3604
+ *
3605
+ * ```json
3606
+ * { "name": "Mute", "type": "UiMuteToggle" }
3607
+ * ```
3608
+ *
3609
+ * Checked means silent. Like the slider it writes `engine.audio`, which
3610
+ * persists itself.
3611
+ */
3612
+ var UiMuteToggle = class extends UiToggle {
3613
+ static typeName = "UiMuteToggle";
3614
+ static props = {
3615
+ ...UiToggle.props,
3616
+ label: { default: "" }
3617
+ };
3618
+ lastPushed = null;
3619
+ _build() {
3620
+ if (!this.label) this.label = translated(this.tree?.engine ?? null, "settings.mute", "Mute");
3621
+ return super._build();
3622
+ }
3623
+ _sync() {
3624
+ const audio = this.tree?.engine?.audio;
3625
+ if (audio) {
3626
+ if (audio.muted !== this.lastPushed) {
3627
+ this.lastPushed = audio.muted;
3628
+ this.value = audio.muted;
3629
+ } else if (this.value !== audio.muted) {
3630
+ audio.muted = this.value;
3631
+ this.lastPushed = audio.muted;
3632
+ }
3633
+ }
3634
+ super._sync();
3635
+ }
3636
+ };
3359
3637
  //#endregion
3360
3638
  //#region src/core/register.ts
3361
3639
  /**
@@ -3382,6 +3660,8 @@ function registerCoreNodes() {
3382
3660
  registerNode(UiQualitySelect);
3383
3661
  registerNode(UiFrameCapSelect);
3384
3662
  registerNode(UiRenderScaleSelect);
3663
+ registerNode(UiVolumeSlider);
3664
+ registerNode(UiMuteToggle);
3385
3665
  }
3386
3666
  //#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 };
3667
+ export { Localization as A, SFX_PRESET_NAMES as B, qualityCaps as C, suggestQuality as D, readDeviceHints as E, isAudioContextAvailable as F, fadeGain as G, MusicManager as H, ROLLOFF_MODELS as I, AudioBuses as K, spatialGain as L, suggestLocale as M, translationKey as N, LogManager as O, SfxEngine as P, spatialPan as R, Settings as S, qualityRendering as T, WebAudioMusicBackend as U, synthSfx as V, crossfadeGains 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, T_PREFIX as j, BASE_LOCALE as k, UiSelect as l, UiButton as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, UiBar as v, qualityEnvironment as w, Engine as x, UiText as y, SFX_PRESETS as z };
@@ -1,5 +1,5 @@
1
- import { b as effectiveOrder, f as Node } from "./loader-B-Gft32x.js";
2
- import { t as registerCoreNodes } from "./register-DVwlnZAZ.js";
1
+ import { b as effectiveOrder, f as Node } from "./loader-BTkHYrQn.js";
2
+ import { t as registerCoreNodes } from "./register-BTomIiYG.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
5
5
  import { i as getNodeSchema, l as registerNode } from "./registry-C7u42TID.js";
@@ -1,4 +1,4 @@
1
- import { b as Engine } from "./behavior-uPEuZrUB.js";
1
+ import { b as Engine } from "./behavior-Do0Da56m.js";
2
2
  import { s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/core/audit.d.ts
@@ -1,5 +1,5 @@
1
- import { E as behaviorSchema, w as parseNodePath } from "./loader-B-Gft32x.js";
2
- import { D as translationKey } from "./register-DVwlnZAZ.js";
1
+ import { E as behaviorSchema, w as parseNodePath } from "./loader-BTkHYrQn.js";
2
+ import { N as translationKey } from "./register-BTomIiYG.js";
3
3
  import { i as getNodeSchema } from "./registry-C7u42TID.js";
4
4
  //#region src/core/node-refs.ts
5
5
  /**
@@ -1,5 +1,5 @@
1
- import { C as diagnose, M as Signal, f as Node, n as loadScene, t as buildNodeJson } from "./loader-B-Gft32x.js";
2
- import { h as Engine, t as registerCoreNodes } from "./register-DVwlnZAZ.js";
1
+ import { C as diagnose, M as Signal, f as Node, n as loadScene, t as buildNodeJson } from "./loader-BTkHYrQn.js";
2
+ import { t as registerCoreNodes, x as Engine } from "./register-BTomIiYG.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
5
  import { l as registerNode } from "./registry-C7u42TID.js";
@@ -1,4 +1,4 @@
1
- import { Dt as Node, Nt as Signal, b as Engine } from "./behavior-uPEuZrUB.js";
1
+ import { Dt as Node, Nt as Signal, b as Engine } from "./behavior-Do0Da56m.js";
2
2
  import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/net/types.d.ts
@@ -161,6 +161,6 @@ function newUid() {
161
161
  //#endregion
162
162
  //#region src/index.ts
163
163
  /** Engine version. Kept in sync with package.json by the release pipeline. */
164
- const VERSION = "0.52.0";
164
+ const VERSION = "0.53.0";
165
165
  //#endregion
166
166
  export { findPath as a, preloadUrls as i, newUid as n, gridFromRows as o, assetUrls as r, VERSION as t };
@@ -1,3 +1,32 @@
1
+ import { b as AudioPlayer } from "./register-BTomIiYG.js";
2
+ //#region src/core/audio-errors.ts
3
+ /** Every AudioPlayer under `root` whose source could not be played. */
4
+ function audioErrors(root) {
5
+ const out = [];
6
+ const walk = (node) => {
7
+ if (node instanceof AudioPlayer && node.loadError) out.push({
8
+ ref: node.getPath(),
9
+ url: node.src,
10
+ error: node.loadError
11
+ });
12
+ for (const child of node.children) walk(child);
13
+ };
14
+ if (root) walk(root);
15
+ return out;
16
+ }
17
+ //#endregion
18
+ //#region src/core/canvas-gestures.ts
19
+ /** Give the GAME the touch gestures over its own canvas. */
20
+ function claimCanvasGestures(canvas) {
21
+ const style = canvas?.style;
22
+ if (!style) return;
23
+ style.touchAction = "none";
24
+ style.userSelect = "none";
25
+ style.webkitUserSelect = "none";
26
+ style.webkitTouchCallout = "none";
27
+ style.webkitTapHighlightColor = "transparent";
28
+ }
29
+ //#endregion
1
30
  //#region src/core/editor-switch.ts
2
31
  /**
3
32
  * Cover the page while it changes what it is.
@@ -295,4 +324,4 @@ function defaultReport(what, error) {
295
324
  console.error(`[incanto] teardown: ${what} failed (continuing)`, error);
296
325
  }
297
326
  //#endregion
298
- export { openBundledEditor as a, devServerLibrary as i, pauseWhenHidden as n, poseFromRenderer as o, crossFade as r, teardown as t };
327
+ export { openBundledEditor as a, audioErrors as c, devServerLibrary as i, pauseWhenHidden as n, poseFromRenderer as o, crossFade as r, claimCanvasGestures as s, teardown as t };
@@ -1,13 +1,13 @@
1
- import { A as registerBehavior, n as loadScene, s as resolveViewport } from "./loader-B-Gft32x.js";
2
- import { h as Engine } from "./register-DVwlnZAZ.js";
1
+ import { A as registerBehavior, n as loadScene, s as resolveViewport } from "./loader-BTkHYrQn.js";
2
+ import { x as Engine } from "./register-BTomIiYG.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
5
  import { i as getNodeSchema, s as mergeStaticProps } from "./registry-C7u42TID.js";
6
- import { n as startRecording } from "./replay-BicPOMX0.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-B6jqvYeM.js";
8
- import { t as registerNodes2D } from "./register-Trx7WHnD.js";
9
- import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-DSZwsKPs.js";
10
- import { n as registerNodesNet, t as createSplitScreen } from "./split-screen-CZ9ccBBQ.js";
6
+ import { n as startRecording } from "./replay-DYNUL4BU.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-CRYw_Q-T.js";
8
+ import { t as registerNodes2D } from "./register-DROK2l7J.js";
9
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-SkGanr2s.js";
10
+ import { n as registerNodesNet, t as createSplitScreen } from "./split-screen-CaF9hO7g.js";
11
11
  import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
12
12
  //#region src/test/framing.ts
13
13
  /**
@@ -1718,10 +1718,10 @@ async function runScript(json, opts) {
1718
1718
  engine.setScene(scene);
1719
1719
  const physics = opts.physics ?? "auto";
1720
1720
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1721
- const { enablePhysics2D } = await import("./physics-2d-Dns-oZlE.js").then((n) => n.r);
1721
+ const { enablePhysics2D } = await import("./physics-2d-BhZZ-HAp.js").then((n) => n.r);
1722
1722
  await enablePhysics2D(engine);
1723
1723
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1724
- const { enablePhysics3D } = await import("./physics-3d-BhI0ehpe.js").then((n) => n.r);
1724
+ const { enablePhysics3D } = await import("./physics-3d-DmyO2oaN.js").then((n) => n.r);
1725
1725
  await enablePhysics3D(engine);
1726
1726
  }
1727
1727
  const failures = [];
@@ -1834,10 +1834,10 @@ async function createPlaySession(json, opts = {}) {
1834
1834
  engine.setScene(scene);
1835
1835
  const physics = opts.physics ?? "auto";
1836
1836
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1837
- const { enablePhysics2D } = await import("./physics-2d-Dns-oZlE.js").then((n) => n.r);
1837
+ const { enablePhysics2D } = await import("./physics-2d-BhZZ-HAp.js").then((n) => n.r);
1838
1838
  await enablePhysics2D(engine);
1839
1839
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1840
- const { enablePhysics3D } = await import("./physics-3d-BhI0ehpe.js").then((n) => n.r);
1840
+ const { enablePhysics3D } = await import("./physics-3d-DmyO2oaN.js").then((n) => n.r);
1841
1841
  await enablePhysics3D(engine);
1842
1842
  }
1843
1843
  const stepMs = 1e3 / (opts.fixedHz ?? 60);
package/dist/test.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { At as LogEntry, Dt as Node, P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-uPEuZrUB.js";
1
+ import { At as LogEntry, Dt as Node, P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-Do0Da56m.js";
2
2
  import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
3
- import { t as LoadSceneOptions } from "./loader-B9iTqs27.js";
4
- import { l as auditScene, o as IncantoError, r as ReplayJson } from "./replay-BU1CCM15.js";
5
- import { l as LocalGameServerOptions, n as SplitScreenPlayer } from "./split-screen-paxkQs_q.js";
3
+ import { t as LoadSceneOptions } from "./loader-Cu_7kJDy.js";
4
+ import { l as auditScene, o as IncantoError, r as ReplayJson } from "./replay-9Fy6C10F.js";
5
+ import { l as LocalGameServerOptions, n as SplitScreenPlayer } from "./split-screen-DNmcX1Pz.js";
6
6
 
7
7
  //#region src/test/facing.d.ts
8
8
  interface FacingOptions {
package/dist/test.js CHANGED
@@ -1,3 +1,3 @@
1
- import { r as auditScene } from "./replay-BicPOMX0.js";
2
- import { S as framingText, _ as failingReplays, a as registerAllNodes, b as playtestText, c as ladderText, d as multiplayText, f as playMultiplayer, g as facingText, h as facingReport, i as findFloatingProps, l as ladderVerdict, m as feelText, n as createPlaySession, o as runScript, p as feelReport, r as describeCapture, s as validateScene, t as captureScene, u as multiplayProblems, v as findPlayer, x as describeFraming, y as playtest } from "./test-DAojuFdb.js";
1
+ import { r as auditScene } from "./replay-DYNUL4BU.js";
2
+ import { S as framingText, _ as failingReplays, a as registerAllNodes, b as playtestText, c as ladderText, d as multiplayText, f as playMultiplayer, g as facingText, h as facingReport, i as findFloatingProps, l as ladderVerdict, m as feelText, n as createPlaySession, o as runScript, p as feelReport, r as describeCapture, s as validateScene, t as captureScene, u as multiplayProblems, v as findPlayer, x as describeFraming, y as playtest } from "./test-CJRsciFk.js";
3
3
  export { auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, ladderText, ladderVerdict, multiplayProblems, multiplayText, playMultiplayer, playtest, playtestText, registerAllNodes, runScript, validateScene };
@@ -80,8 +80,8 @@ var TouchControls = class {
80
80
  const base = doc.createElement("div");
81
81
  style(base, {
82
82
  position: "absolute",
83
- left: `${24 + index * 140}px`,
84
- bottom: "24px",
83
+ left: `calc(${24 + index * 140}px + env(safe-area-inset-left))`,
84
+ bottom: "calc(24px + env(safe-area-inset-bottom))",
85
85
  width: "120px",
86
86
  height: "120px",
87
87
  borderRadius: "50%",
@@ -150,8 +150,8 @@ var TouchControls = class {
150
150
  button.textContent = action;
151
151
  style(button, {
152
152
  position: "absolute",
153
- right: "24px",
154
- bottom: `${24 + index * 80}px`,
153
+ right: "calc(24px + env(safe-area-inset-right))",
154
+ bottom: `calc(${24 + index * 80}px + env(safe-area-inset-bottom))`,
155
155
  width: "64px",
156
156
  height: "64px",
157
157
  borderRadius: "50%",
package/dist/vite.js CHANGED
@@ -1,6 +1,6 @@
1
- import { t as VERSION } from "./src-5gbZO47I.js";
1
+ import { t as VERSION } from "./src-C9xyZW7M.js";
2
2
  import { n as diffSignatures } from "./frame-report-njybhZon.js";
3
- import { s as validateScene } from "./test-DAojuFdb.js";
3
+ import { s as validateScene } from "./test-CJRsciFk.js";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
6
6
  //#region src/vite/frame-endpoint.ts