incanto 0.32.0 → 0.34.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 (49) hide show
  1. package/dist/2d.d.ts +59 -2
  2. package/dist/2d.js +3 -3
  3. package/dist/3d.d.ts +8 -3
  4. package/dist/3d.js +4 -4
  5. package/dist/{behavior-D_jMpFh8.d.ts → behavior-CtPScDMo.d.ts} +149 -1
  6. package/dist/{create-game-bKHgHcsZ.js → create-game-CFNqoqOX.js} +13 -6
  7. package/dist/{create-game-gXI7PYl0.js → create-game-QTfghzwM.js} +11 -5
  8. package/dist/debug.d.ts +1 -1
  9. package/dist/{duplicate-KRPtUtzl.js → duplicate-DlOvknDO.js} +1 -1
  10. package/dist/editor.js +35 -0
  11. package/dist/{environment-presets--DigHNg4.js → environment-presets-B6WiUsaO.js} +2 -2
  12. package/dist/{gameplay-BftxM_It.js → gameplay-BxFtmfSe.js} +90 -2
  13. package/dist/gameplay.d.ts +1 -1
  14. package/dist/gameplay.js +1 -1
  15. package/dist/index.d.ts +175 -23
  16. package/dist/index.js +5 -58
  17. package/dist/{loader-BlaRQGaA.js → loader-BwR0DxeM.js} +66 -0
  18. package/dist/{loader-BYBrqTxP.d.ts → loader-DmXfj6Uv.d.ts} +1 -1
  19. package/dist/net.d.ts +1 -1
  20. package/dist/net.js +3 -3
  21. package/dist/{pathfinding-BwhqPD3i.d.ts → pathfinding-DRCe89SI.d.ts} +1 -1
  22. package/dist/{physics-2d-D9wquBvK.js → physics-2d-BuyAwsW5.js} +12 -2
  23. package/dist/{physics-3d-CnPygVGo.js → physics-3d-C8Kn_Nyb.js} +55 -9
  24. package/dist/react.d.ts +1 -1
  25. package/dist/react.js +1 -1
  26. package/dist/{register-tkR_8tWg.js → register-BLPC10Mj.js} +145 -8
  27. package/dist/{register-Dzkd6-os.js → register-G3edsjJ2.js} +752 -16
  28. package/dist/{register-CUY284Is.js → register-OodmuUMK.js} +2 -2
  29. package/dist/{test-WwRIlXsK.js → test-C4B5znap.js} +10 -10
  30. package/dist/test.d.ts +2 -2
  31. package/dist/test.js +1 -1
  32. package/dist/vite.js +1 -1
  33. package/editor/assets/{agent8-C5k1nTCH.js → agent8-BTODHtdM.js} +1 -1
  34. package/editor/assets/{debug-BT_0mjk-.js → debug-CJEz4EwC.js} +1 -1
  35. package/editor/assets/{index-gfyrByWw.js → index-BYCso8Bf.js} +90 -90
  36. package/editor/index.html +1 -1
  37. package/package.json +1 -1
  38. package/schemas/scene.schema.json +667 -11
  39. package/skills/incanto-audio.md +43 -0
  40. package/skills/incanto-building-2d-games.md +36 -0
  41. package/skills/incanto-hud.md +97 -0
  42. package/skills/incanto-node-reference.md +131 -3
  43. package/skills/incanto-physics-and-input.md +50 -0
  44. package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +40 -19
  45. package/templates-app/beacon-isle-3d/package.json +1 -1
  46. package/templates-app/tps-3d/docs/project-3d-rules.md +41 -19
  47. package/templates-app/tps-3d/package.json +1 -1
  48. package/templates-app/village-quest-3d/docs/project-3d-rules.md +40 -19
  49. package/templates-app/village-quest-3d/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { f as Node, h as InputMap, x as Signal } from "./loader-BlaRQGaA.js";
1
+ import { f as Node, h as InputMap, x as Signal } from "./loader-BwR0DxeM.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-CJdGpT2V.js";
@@ -28,19 +28,19 @@ var AudioBuses = class {
28
28
  return this._master;
29
29
  }
30
30
  set master(v) {
31
- this._master = this.assign(this._master, v);
31
+ this.assign("_master", v);
32
32
  }
33
33
  get sfx() {
34
34
  return this._sfx;
35
35
  }
36
36
  set sfx(v) {
37
- this._sfx = this.assign(this._sfx, v);
37
+ this.assign("_sfx", v);
38
38
  }
39
39
  get music() {
40
40
  return this._music;
41
41
  }
42
42
  set music(v) {
43
- this._music = this.assign(this._music, v);
43
+ this.assign("_music", v);
44
44
  }
45
45
  get muted() {
46
46
  return this._muted;
@@ -57,11 +57,20 @@ var AudioBuses = class {
57
57
  return this._master * busGain * clamp01$3(sourceVolume);
58
58
  }
59
59
  /** Clamp + finite-guard a new value; emit only on a real change. */
60
- assign(current, next) {
61
- if (!Number.isFinite(next)) return current;
60
+ /**
61
+ * Write THEN emit.
62
+ *
63
+ * This used to emit from inside the expression assigning the field, so every
64
+ * listener of `changed` read the OLD value — including the thing the signal
65
+ * exists for ("live re-gain of playing sounds"), which therefore re-gained to
66
+ * the volume you just left.
67
+ */
68
+ assign(key, next) {
69
+ if (!Number.isFinite(next)) return;
62
70
  const clamped = clamp01$3(next);
63
- if (clamped !== current) this.changed.emit();
64
- return clamped;
71
+ if (clamped === this[key]) return;
72
+ this[key] = clamped;
73
+ this.changed.emit();
65
74
  }
66
75
  };
67
76
  function clamp01$3(v) {
@@ -1009,6 +1018,196 @@ var LogManager = class {
1009
1018
  }
1010
1019
  };
1011
1020
  //#endregion
1021
+ //#region src/core/save.ts
1022
+ const memoryStores = /* @__PURE__ */ new Map();
1023
+ function createSaveStore(namespace) {
1024
+ const prefix = `incanto:${namespace}:`;
1025
+ const local = storageOrNull();
1026
+ if (!local) {
1027
+ let mem = memoryStores.get(namespace);
1028
+ if (!mem) {
1029
+ mem = /* @__PURE__ */ new Map();
1030
+ memoryStores.set(namespace, mem);
1031
+ }
1032
+ const backing = mem;
1033
+ return {
1034
+ get: (key, fallback) => parse(backing.get(key), fallback),
1035
+ set: (key, value) => void backing.set(key, JSON.stringify(value)),
1036
+ remove: (key) => void backing.delete(key),
1037
+ clear: () => backing.clear()
1038
+ };
1039
+ }
1040
+ return {
1041
+ get: (key, fallback) => parse(local.getItem(prefix + key) ?? void 0, fallback),
1042
+ set: (key, value) => local.setItem(prefix + key, JSON.stringify(value)),
1043
+ remove: (key) => local.removeItem(prefix + key),
1044
+ clear: () => {
1045
+ const doomed = [];
1046
+ for (let i = 0; i < local.length; i++) {
1047
+ const k = local.key(i);
1048
+ if (k?.startsWith(prefix)) doomed.push(k);
1049
+ }
1050
+ for (const k of doomed) local.removeItem(k);
1051
+ }
1052
+ };
1053
+ }
1054
+ function parse(raw, fallback) {
1055
+ if (raw === void 0 || raw === null) return fallback;
1056
+ try {
1057
+ return JSON.parse(raw);
1058
+ } catch {
1059
+ return fallback;
1060
+ }
1061
+ }
1062
+ function storageOrNull() {
1063
+ try {
1064
+ if (typeof localStorage === "undefined") return null;
1065
+ const probe = "__incanto_probe__";
1066
+ localStorage.setItem(probe, "1");
1067
+ localStorage.removeItem(probe);
1068
+ return localStorage;
1069
+ } catch {
1070
+ return null;
1071
+ }
1072
+ }
1073
+ //#endregion
1074
+ //#region src/core/settings.ts
1075
+ const DEFAULTS = {
1076
+ master: 1,
1077
+ sfx: 1,
1078
+ music: 1,
1079
+ muted: false,
1080
+ quality: "high",
1081
+ sensitivity: 1,
1082
+ invertY: false,
1083
+ reduceMotion: false
1084
+ };
1085
+ const TIERS = [
1086
+ "low",
1087
+ "medium",
1088
+ "high"
1089
+ ];
1090
+ /**
1091
+ * A default tier from what the browser will admit to.
1092
+ *
1093
+ * Deliberately crude: this picks a STARTING point, and the options menu is the
1094
+ * real answer. A phone that reports four cores and a coarse pointer should not
1095
+ * open on the setting tuned for a desktop GPU.
1096
+ */
1097
+ function suggestQuality(hints) {
1098
+ const cores = hints.hardwareConcurrency ?? 8;
1099
+ const memory = hints.deviceMemory ?? 8;
1100
+ if (hints.coarsePointer && (cores <= 6 || memory <= 4)) return "low";
1101
+ if (cores <= 4 || memory <= 4 || (hints.maxTextureSize ?? 8192) < 8192) return "low";
1102
+ if (cores <= 8 || memory <= 8) return "medium";
1103
+ return "high";
1104
+ }
1105
+ /** Read what this environment will tell us, safely, without touching WebGL. */
1106
+ function readDeviceHints() {
1107
+ const nav = globalThis.navigator;
1108
+ const mm = globalThis.matchMedia;
1109
+ const out = {};
1110
+ if (typeof nav?.hardwareConcurrency === "number") out.hardwareConcurrency = nav.hardwareConcurrency;
1111
+ if (typeof nav?.deviceMemory === "number") out.deviceMemory = nav.deviceMemory;
1112
+ try {
1113
+ if (mm) out.coarsePointer = mm("(pointer: coarse)").matches;
1114
+ } catch {}
1115
+ return out;
1116
+ }
1117
+ var Settings = class {
1118
+ /** Fires after any change, with the key that changed. */
1119
+ changed = new Signal();
1120
+ store;
1121
+ values;
1122
+ constructor(namespace = "settings", store) {
1123
+ this.store = store ?? createSaveStore(namespace);
1124
+ this.values = { ...DEFAULTS };
1125
+ for (const key of Object.keys(DEFAULTS)) {
1126
+ const saved = this.store.get(key, DEFAULTS[key]);
1127
+ this.values[key] = saved;
1128
+ }
1129
+ if (!TIERS.includes(this.values.quality)) this.values.quality = DEFAULTS.quality;
1130
+ }
1131
+ get(key) {
1132
+ return this.values[key];
1133
+ }
1134
+ set(key, value) {
1135
+ if (this.values[key] === value) return;
1136
+ this.values[key] = value;
1137
+ this.store.set(key, value);
1138
+ this.changed.emit(key);
1139
+ }
1140
+ /** Every value at once — for an options screen, or a save-file dump. */
1141
+ all() {
1142
+ return { ...this.values };
1143
+ }
1144
+ /** Back to defaults, on disk too. */
1145
+ reset() {
1146
+ for (const key of Object.keys(DEFAULTS)) this.set(key, DEFAULTS[key]);
1147
+ }
1148
+ /**
1149
+ * Push the saved volumes into the engine's buses, and keep writing them back
1150
+ * whenever anything changes them.
1151
+ *
1152
+ * The subscription is the point: a game that sets `engine.audio.music = 0.4`
1153
+ * from a slider has already persisted it, without knowing this class exists.
1154
+ */
1155
+ bindAudio(audio) {
1156
+ audio.master = this.values.master;
1157
+ audio.sfx = this.values.sfx;
1158
+ audio.music = this.values.music;
1159
+ audio.muted = this.values.muted;
1160
+ return audio.changed.connect(() => {
1161
+ this.set("master", audio.master);
1162
+ this.set("sfx", audio.sfx);
1163
+ this.set("music", audio.music);
1164
+ this.set("muted", audio.muted);
1165
+ });
1166
+ }
1167
+ };
1168
+ /**
1169
+ * What a quality tier MEANS, as an environment patch.
1170
+ *
1171
+ * Only the levers that actually cost: shadows, post, clouds, and the resolution
1172
+ * the renderer draws at. Deliberately an `environment` patch, because
1173
+ * `setEnvironment3D(engine, patch)` already applies one live and validated —
1174
+ * this is not a second rendering pipeline, it is three presets for the one that
1175
+ * exists.
1176
+ */
1177
+ function qualityEnvironment(tier) {
1178
+ if (tier === "low") return {
1179
+ shadows: { enabled: false },
1180
+ bloom: { enabled: false },
1181
+ post: { enabled: false },
1182
+ clouds: { enabled: false },
1183
+ rendering: {
1184
+ antialias: false,
1185
+ pixelRatio: 1
1186
+ }
1187
+ };
1188
+ if (tier === "medium") return {
1189
+ shadows: {
1190
+ enabled: true,
1191
+ static: true
1192
+ },
1193
+ bloom: { enabled: true },
1194
+ post: { enabled: false },
1195
+ rendering: {
1196
+ antialias: true,
1197
+ pixelRatio: 1
1198
+ }
1199
+ };
1200
+ return {
1201
+ shadows: { enabled: true },
1202
+ bloom: { enabled: true },
1203
+ post: { enabled: true },
1204
+ rendering: {
1205
+ antialias: true,
1206
+ pixelRatio: "device"
1207
+ }
1208
+ };
1209
+ }
1210
+ //#endregion
1012
1211
  //#region src/core/engine.ts
1013
1212
  const MAX_DT_SECONDS = .25;
1014
1213
  function rafScheduler(cb) {
@@ -1047,9 +1246,27 @@ var Engine = class {
1047
1246
  rng;
1048
1247
  /** The engine log channel (debug overlay + headless harness tail this). */
1049
1248
  log = new LogManager();
1249
+ /**
1250
+ * Screen pixel → the node under it, when a renderer has offered one.
1251
+ *
1252
+ * The raycast has always existed (`Renderer3D.pick` / `Renderer2D.pick`) and
1253
+ * was reachable only from whoever held the renderer — so a Behavior, which
1254
+ * holds a node, could not use it. Every mouse-driven genre needs exactly this
1255
+ * and had no way to ask. Instance-scoped, set by the renderer on construction
1256
+ * and cleared on dispose; null when nothing renders (headless).
1257
+ */
1258
+ picker = null;
1050
1259
  /** Global volume buses — `master` × `sfx`/`music` gain + `muted`. AudioPlayer
1051
1260
  * routes its volume through this; games set it for global volume/mute. */
1052
1261
  audio = new AudioBuses();
1262
+ /**
1263
+ * Persisted player settings — volume, quality, sensitivity.
1264
+ *
1265
+ * `createSaveStore` was good and had zero callers, so every game's volume
1266
+ * reset on reload. Reading it here costs nothing (localStorage, once) and
1267
+ * `bindAudio` makes the volumes write themselves.
1268
+ */
1269
+ settings = new Settings();
1053
1270
  /** Low-latency procedural-SFX player (WebAudio, headless-safe). AudioPlayer
1054
1271
  * presets play through it; games may call `engine.sfx.play(...)` directly. */
1055
1272
  sfx = new SfxEngine();
@@ -1510,10 +1727,17 @@ var HudLayer = class extends Node {
1510
1727
  static typeName = "HudLayer";
1511
1728
  static props = {
1512
1729
  zIndex: { default: 100 },
1513
- visible: { default: true }
1730
+ visible: { default: true },
1731
+ /**
1732
+ * Arrow keys / d-pad move focus between the focusable widgets under this
1733
+ * layer, Enter / A activates. OFF by default — a game whose HUD has a
1734
+ * button must not lose its arrow keys the moment one exists.
1735
+ */
1736
+ focusNavigation: { default: false }
1514
1737
  };
1515
1738
  zIndex = 100;
1516
1739
  visible = true;
1740
+ focusNavigation = false;
1517
1741
  /** @internal root overlay element (null headless). */
1518
1742
  _element = null;
1519
1743
  slots = /* @__PURE__ */ new Map();
@@ -1554,6 +1778,66 @@ var HudLayer = class extends Node {
1554
1778
  this._mount();
1555
1779
  this._element.style.display = this.visible ? "" : "none";
1556
1780
  }
1781
+ if (this.focusNavigation) this.stepFocus();
1782
+ }
1783
+ /**
1784
+ * Arrow keys / d-pad move the focus, Enter / A activates it.
1785
+ *
1786
+ * Off by default: a game whose HUD has a button must not lose its arrow keys
1787
+ * to a menu the moment one exists. Turn it on for the screens that ARE menus
1788
+ * (`"focusNavigation": true` on the pause panel's layer), and off again when
1789
+ * play resumes.
1790
+ */
1791
+ stepFocus() {
1792
+ const engine = this.tree?.engine;
1793
+ if (!engine) return;
1794
+ const items = this.focusables();
1795
+ if (items.length === 0) return;
1796
+ const input = engine.input;
1797
+ const down = input.keyJustPressed("ArrowDown") || input.keyJustPressed("Pad13");
1798
+ const up = input.keyJustPressed("ArrowUp") || input.keyJustPressed("Pad12");
1799
+ const left = input.keyJustPressed("ArrowLeft") || input.keyJustPressed("Pad14");
1800
+ const right = input.keyJustPressed("ArrowRight") || input.keyJustPressed("Pad15");
1801
+ const confirm = input.keyJustPressed("Enter") || input.keyJustPressed("Pad0");
1802
+ let index = items.findIndex((w) => w._focused);
1803
+ if (index === -1 && (down || up || confirm)) index = 0;
1804
+ else if (down) index = (index + 1) % items.length;
1805
+ else if (up) index = (index - 1 + items.length) % items.length;
1806
+ if (index !== -1) {
1807
+ for (const w of items) {
1808
+ const next = w === items[index];
1809
+ if (next !== w._focused) {
1810
+ w._focused = next;
1811
+ w._paintFocus();
1812
+ }
1813
+ }
1814
+ const focused = items[index];
1815
+ if (focused) {
1816
+ if (confirm) focused._activate(0);
1817
+ else if (left) focused._activate(-1);
1818
+ else if (right) focused._activate(1);
1819
+ }
1820
+ }
1821
+ }
1822
+ /** Focusable widgets under this layer, in tree order. */
1823
+ focusables() {
1824
+ const out = [];
1825
+ const walk = (node) => {
1826
+ if (node instanceof HudWidgetBase && node.focusable && node.visible) out.push(node);
1827
+ for (const child of node.children) walk(child);
1828
+ };
1829
+ walk(this);
1830
+ return out;
1831
+ }
1832
+ /** Move focus to a widget by hand — opening a menu should start somewhere. */
1833
+ focus(widget) {
1834
+ for (const w of this.focusables()) {
1835
+ const next = w === widget;
1836
+ if (next !== w._focused) {
1837
+ w._focused = next;
1838
+ w._paintFocus();
1839
+ }
1840
+ }
1557
1841
  }
1558
1842
  /** @internal Widgets mount into per-anchor flex columns. */
1559
1843
  _slot(anchor) {
@@ -1568,6 +1852,22 @@ var HudLayer = class extends Node {
1568
1852
  return slot;
1569
1853
  }
1570
1854
  };
1855
+ /**
1856
+ * The widget an element belongs to.
1857
+ *
1858
+ * A pick lands on whatever div is under the cursor — usually a CHILD of the
1859
+ * widget (an icon inside a slot), so the walk upward is the point.
1860
+ */
1861
+ const widgetOf = /* @__PURE__ */ new WeakMap();
1862
+ function widgetFromElement(el) {
1863
+ for (let n = el; n; n = n.parentElement) {
1864
+ const found = widgetOf.get(n);
1865
+ if (found) return found;
1866
+ }
1867
+ return null;
1868
+ }
1869
+ /** The drag in flight, if any. One pointer, one drag. */
1870
+ let dragging = null;
1571
1871
  /** Shared plumbing: mount into the parent HudLayer's anchor slot. */
1572
1872
  var HudWidgetBase = class extends Node {
1573
1873
  static props = {
@@ -1585,10 +1885,46 @@ var HudWidgetBase = class extends Node {
1585
1885
  "bottomRight"
1586
1886
  ]
1587
1887
  },
1588
- visible: { default: true }
1888
+ visible: { default: true },
1889
+ /**
1890
+ * Can menu navigation land on this? Interactive widgets default to true.
1891
+ *
1892
+ * A menu you can only click is not playable on a controller, and "add
1893
+ * gamepad support" is not a thing a JSON scene could express at all.
1894
+ */
1895
+ focusable: { default: false },
1896
+ /**
1897
+ * Can the player pick this up and drop it somewhere? An inventory item is
1898
+ * a `draggable` widget; a slot is a `dropTarget`.
1899
+ */
1900
+ draggable: { default: false },
1901
+ /** Can something be dropped ON this? */
1902
+ dropTarget: { default: false }
1589
1903
  };
1904
+ static signals = [
1905
+ "dragStarted",
1906
+ "dragCancelled",
1907
+ "droppedOn",
1908
+ "dropped"
1909
+ ];
1590
1910
  anchor = "topLeft";
1591
1911
  visible = true;
1912
+ focusable = false;
1913
+ draggable = false;
1914
+ dropTarget = false;
1915
+ /** @internal Set by the owning HudLayer while this widget has focus. */
1916
+ _focused = false;
1917
+ /**
1918
+ * @internal Confirm (`dir` 0) or nudge (-1 left / +1 right) — what pressing
1919
+ * A or an arrow ON this widget means. Default: nothing.
1920
+ */
1921
+ _activate(_dir) {}
1922
+ /** @internal Draw the focus ring. Overridable if a widget wants its own. */
1923
+ _paintFocus() {
1924
+ if (!this._element) return;
1925
+ this._element.style.outline = this._focused ? "2px solid #6ee7dc" : "";
1926
+ this._element.style.outlineOffset = this._focused ? "2px" : "";
1927
+ }
1592
1928
  /** @internal */
1593
1929
  _element = null;
1594
1930
  layer() {
@@ -1597,17 +1933,79 @@ var HudWidgetBase = class extends Node {
1597
1933
  }
1598
1934
  onReady() {
1599
1935
  if (!hasDom()) return;
1600
- const slot = this.layer()?._slot(this.anchor);
1601
- if (!slot) return;
1936
+ this._mountWidget();
1937
+ }
1938
+ /**
1939
+ * @internal Build and attach, once a host exists.
1940
+ *
1941
+ * A widget inside a UiPanel mounts into the PANEL, not the layer's anchor
1942
+ * slot — that is what turns structure in the tree into structure on screen,
1943
+ * and it is what a menu is. Ready order does not decide it: the panel builds
1944
+ * on demand when a child asks (`_container`), and `update` retries for
1945
+ * anything attached at runtime before its host existed.
1946
+ */
1947
+ _mountWidget() {
1948
+ if (this._element || !hasDom()) return;
1949
+ const host = this.host() ?? this.layer()?._slot(this.anchor);
1950
+ if (!host) return;
1602
1951
  this._element = this._build();
1603
- slot.appendChild(this._element);
1952
+ widgetOf.set(this._element, this);
1953
+ if (this.draggable || this.dropTarget) this._wireDrag(this._element);
1954
+ host.appendChild(this._element);
1955
+ }
1956
+ /**
1957
+ * Drag and drop, on the DOM the engine already builds.
1958
+ *
1959
+ * An inventory is the one screen where "click it" is not enough, and every
1960
+ * game that wanted one dropped out of scene JSON to hand-roll pointer
1961
+ * handlers. The whole gesture is four signals and no new node type: a
1962
+ * `draggable` widget emits `dragStarted` and, if it lands on one,
1963
+ * `droppedOn(target)`; a `dropTarget` emits `dropped(source)`. Who owns the
1964
+ * ITEM is the game's business — this reports the gesture, not a model.
1965
+ */
1966
+ _wireDrag(el) {
1967
+ el.style.touchAction = "none";
1968
+ if (this.draggable) {
1969
+ el.style.cursor = "grab";
1970
+ el.addEventListener("pointerdown", (event) => {
1971
+ if (!this.draggable) return;
1972
+ event.preventDefault();
1973
+ el.setPointerCapture?.(event.pointerId);
1974
+ dragging = this;
1975
+ el.style.opacity = "0.6";
1976
+ this.emit("dragStarted", this);
1977
+ });
1978
+ el.addEventListener("pointerup", (event) => {
1979
+ if (dragging !== this) return;
1980
+ el.style.opacity = "";
1981
+ el.releasePointerCapture?.(event.pointerId);
1982
+ const target = widgetFromElement(document.elementFromPoint?.(event.clientX, event.clientY) ?? null);
1983
+ dragging = null;
1984
+ if (target && target !== this && target.dropTarget) {
1985
+ this.emit("droppedOn", target);
1986
+ target.emit("dropped", this);
1987
+ } else this.emit("dragCancelled", this);
1988
+ });
1989
+ }
1990
+ }
1991
+ /** The nearest ancestor that holds widgets itself (a UiPanel). */
1992
+ host() {
1993
+ for (let p = this.parent; p; p = p.parent) {
1994
+ const holder = p;
1995
+ if (typeof holder._container === "function") return holder._container();
1996
+ if (p instanceof HudLayer) return null;
1997
+ }
1998
+ return null;
1604
1999
  }
1605
2000
  onExitTree() {
1606
2001
  this._element?.remove();
1607
2002
  this._element = null;
1608
2003
  }
1609
2004
  update(_dt) {
1610
- if (!this._element) return;
2005
+ if (!this._element) {
2006
+ this._mountWidget();
2007
+ if (!this._element) return;
2008
+ }
1611
2009
  this._element.style.display = this.visible ? "" : "none";
1612
2010
  this._sync();
1613
2011
  }
@@ -1793,13 +2191,19 @@ var UiButton = class extends HudWidgetBase {
1793
2191
  size: { default: 16 },
1794
2192
  color: { default: "#ffffff" },
1795
2193
  background: { default: "rgba(255,255,255,0.14)" },
1796
- disabled: { default: false }
2194
+ disabled: { default: false },
2195
+ focusable: { default: true }
1797
2196
  };
1798
2197
  text = "OK";
1799
2198
  size = 16;
1800
2199
  color = "#ffffff";
1801
2200
  background = "rgba(255,255,255,0.14)";
1802
2201
  disabled = false;
2202
+ focusable = true;
2203
+ /** Enter / A on a focused button is a press. */
2204
+ _activate(_dir) {
2205
+ this.press();
2206
+ }
1803
2207
  lastText = "";
1804
2208
  /** Programmatic press — same path as a click (tests, gamepad menus). */
1805
2209
  press() {
@@ -2028,6 +2432,333 @@ var Timer = class extends Node {
2028
2432
  }
2029
2433
  };
2030
2434
  //#endregion
2435
+ //#region src/core/nodes/ui-kit.ts
2436
+ function px(n) {
2437
+ return `${n}px`;
2438
+ }
2439
+ /**
2440
+ * A box that holds other widgets — the thing a menu actually is.
2441
+ *
2442
+ * Widgets normally mount into their HudLayer's anchor slot, which is why a
2443
+ * "menu" used to be a flat pile of anchored items with no way to sit them in a
2444
+ * panel, space them, or lay them out in a grid. A `UiPanel` becomes the parent
2445
+ * for every widget under it, so structure in the TREE is structure on SCREEN.
2446
+ *
2447
+ * ```json
2448
+ * { "name": "Pause", "type": "UiPanel", "props": { "anchor": "center", "layout": "column", "gap": 12 },
2449
+ * "children": [
2450
+ * { "name": "Title", "type": "UiText", "props": { "text": "PAUSED", "size": 28 } },
2451
+ * { "name": "Resume", "type": "UiButton", "props": { "label": "Resume" } }
2452
+ * ] }
2453
+ * ```
2454
+ */
2455
+ var UiPanel = class extends HudWidgetBase {
2456
+ static typeName = "UiPanel";
2457
+ static props = {
2458
+ ...HudWidgetBase.props,
2459
+ layout: {
2460
+ default: "column",
2461
+ options: [
2462
+ "column",
2463
+ "row",
2464
+ "grid"
2465
+ ]
2466
+ },
2467
+ /** Columns when `layout: "grid"` — an inventory is a grid of slots. */
2468
+ columns: { default: 4 },
2469
+ gap: { default: 8 },
2470
+ padding: { default: 12 },
2471
+ background: { default: "rgba(0,0,0,0.55)" },
2472
+ radius: { default: 10 },
2473
+ /** 0 = size to the contents. */
2474
+ width: { default: 0 },
2475
+ height: { default: 0 },
2476
+ border: { default: "" }
2477
+ };
2478
+ layout = "column";
2479
+ columns = 4;
2480
+ gap = 8;
2481
+ padding = 12;
2482
+ background = "rgba(0,0,0,0.55)";
2483
+ radius = 10;
2484
+ width = 0;
2485
+ height = 0;
2486
+ border = "";
2487
+ lastKey = "";
2488
+ _build() {
2489
+ const el = document.createElement("div");
2490
+ el.style.cssText = this.css();
2491
+ this.lastKey = this.css();
2492
+ return el;
2493
+ }
2494
+ /**
2495
+ * @internal Children mount HERE, not in the layer's anchor slot.
2496
+ *
2497
+ * Builds on demand: a child asking for its host must not depend on whose
2498
+ * `onReady` the tree happened to run first.
2499
+ */
2500
+ _container() {
2501
+ if (!this._element) this._mountWidget();
2502
+ return this._element;
2503
+ }
2504
+ css() {
2505
+ 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;";
2506
+ }
2507
+ _sync() {
2508
+ const next = this.css();
2509
+ if (next !== this.lastKey && this._element) {
2510
+ this.lastKey = next;
2511
+ this._element.style.cssText = next;
2512
+ }
2513
+ }
2514
+ };
2515
+ /** A picture: an item icon, a portrait, a logo. `src` takes a url or `$assetKey`. */
2516
+ var UiImage = class extends HudWidgetBase {
2517
+ static typeName = "UiImage";
2518
+ static props = {
2519
+ ...HudWidgetBase.props,
2520
+ src: { default: "" },
2521
+ width: { default: 48 },
2522
+ height: { default: 48 },
2523
+ /** How the picture fills its box — `contain` never crops. */
2524
+ fit: {
2525
+ default: "contain",
2526
+ options: [
2527
+ "contain",
2528
+ "cover",
2529
+ "fill"
2530
+ ]
2531
+ },
2532
+ /** Multiply the image by a colour: greyed-out items, team tints. */
2533
+ tint: { default: "" },
2534
+ opacity: { default: 1 }
2535
+ };
2536
+ src = "";
2537
+ width = 48;
2538
+ height = 48;
2539
+ fit = "contain";
2540
+ tint = "";
2541
+ opacity = 1;
2542
+ last = "\0";
2543
+ _build() {
2544
+ const el = document.createElement("div");
2545
+ el.style.cssText = this.css();
2546
+ this.last = this.key();
2547
+ return el;
2548
+ }
2549
+ resolved() {
2550
+ if (!this.src.startsWith("$")) return this.src;
2551
+ return (this.tree?.engine?.scene?.assets)?.[this.src.slice(1)]?.url ?? "";
2552
+ }
2553
+ key() {
2554
+ return `${this.resolved()}|${this.width}|${this.height}|${this.fit}|${this.tint}|${this.opacity}`;
2555
+ }
2556
+ css() {
2557
+ const url = this.resolved();
2558
+ 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;` : "");
2559
+ }
2560
+ _sync() {
2561
+ const next = this.key();
2562
+ if (next !== this.last && this._element) {
2563
+ this.last = next;
2564
+ this._element.style.cssText = this.css();
2565
+ }
2566
+ }
2567
+ };
2568
+ /**
2569
+ * A value the player sets — volume, sensitivity, difficulty.
2570
+ *
2571
+ * Emits `changed(value)`. `incanto-audio.md` promised "a settings slider needs
2572
+ * no extra wiring" while no slider existed; this is that slider.
2573
+ */
2574
+ var UiSlider = class extends HudWidgetBase {
2575
+ static typeName = "UiSlider";
2576
+ static props = {
2577
+ ...HudWidgetBase.props,
2578
+ label: { default: "" },
2579
+ value: { default: .5 },
2580
+ min: { default: 0 },
2581
+ max: { default: 1 },
2582
+ step: { default: .01 },
2583
+ width: { default: 180 },
2584
+ color: { default: "#6ee7dc" },
2585
+ focusable: { default: true }
2586
+ };
2587
+ static signals = [...HudWidgetBase.signals, "changed"];
2588
+ label = "";
2589
+ value = .5;
2590
+ min = 0;
2591
+ max = 1;
2592
+ step = .01;
2593
+ width = 180;
2594
+ color = "#6ee7dc";
2595
+ focusable = true;
2596
+ /** Left/right nudge by one step — the only sane thing a d-pad can mean here. */
2597
+ _activate(dir) {
2598
+ if (dir === 0) return;
2599
+ const next = Math.min(this.max, Math.max(this.min, this.value + dir * this.step));
2600
+ if (next === this.value) return;
2601
+ this.value = next;
2602
+ this.last = next;
2603
+ if (this.input) this.input.value = String(next);
2604
+ this.emit("changed", next);
2605
+ }
2606
+ input = null;
2607
+ text = null;
2608
+ last = NaN;
2609
+ _build() {
2610
+ const wrap = document.createElement("div");
2611
+ 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;";
2612
+ const text = document.createElement("span");
2613
+ text.textContent = this.label;
2614
+ const input = document.createElement("input");
2615
+ input.type = "range";
2616
+ input.min = String(this.min);
2617
+ input.max = String(this.max);
2618
+ input.step = String(this.step);
2619
+ input.value = String(this.value);
2620
+ input.style.cssText = `width:${px(this.width)};accent-color:${this.color};`;
2621
+ input.addEventListener("input", () => {
2622
+ this.value = Number(input.value);
2623
+ this.last = this.value;
2624
+ this.emit("changed", this.value);
2625
+ });
2626
+ if (this.label) wrap.appendChild(text);
2627
+ wrap.appendChild(input);
2628
+ this.input = input;
2629
+ this.text = text;
2630
+ this.last = this.value;
2631
+ return wrap;
2632
+ }
2633
+ _sync() {
2634
+ if (this.input && this.value !== this.last) {
2635
+ this.last = this.value;
2636
+ this.input.value = String(this.value);
2637
+ }
2638
+ if (this.text && this.text.textContent !== this.label) this.text.textContent = this.label;
2639
+ }
2640
+ };
2641
+ /** An on/off switch — mute, invert-Y, fullscreen. Emits `changed(boolean)`. */
2642
+ var UiToggle = class extends HudWidgetBase {
2643
+ static typeName = "UiToggle";
2644
+ static props = {
2645
+ ...HudWidgetBase.props,
2646
+ label: { default: "" },
2647
+ value: { default: false },
2648
+ focusable: { default: true }
2649
+ };
2650
+ static signals = [...HudWidgetBase.signals, "changed"];
2651
+ label = "";
2652
+ value = false;
2653
+ focusable = true;
2654
+ /** Confirm flips it; left/right set it explicitly, which reads better on a pad. */
2655
+ _activate(dir) {
2656
+ const next = dir === 0 ? !this.value : dir > 0;
2657
+ if (next === this.value) return;
2658
+ this.value = next;
2659
+ this.last = next;
2660
+ if (this.input) this.input.checked = next;
2661
+ this.emit("changed", next);
2662
+ }
2663
+ input = null;
2664
+ last = null;
2665
+ _build() {
2666
+ const wrap = document.createElement("label");
2667
+ 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;";
2668
+ const input = document.createElement("input");
2669
+ input.type = "checkbox";
2670
+ input.checked = this.value;
2671
+ input.addEventListener("change", () => {
2672
+ this.value = input.checked;
2673
+ this.last = this.value;
2674
+ this.emit("changed", this.value);
2675
+ });
2676
+ const text = document.createElement("span");
2677
+ text.textContent = this.label;
2678
+ wrap.append(input, text);
2679
+ this.input = input;
2680
+ this.last = this.value;
2681
+ return wrap;
2682
+ }
2683
+ _sync() {
2684
+ if (this.input && this.value !== this.last) {
2685
+ this.last = this.value;
2686
+ this.input.checked = this.value;
2687
+ }
2688
+ }
2689
+ };
2690
+ /** One of several — quality, resolution, language. Emits `changed(value)`. */
2691
+ var UiSelect = class extends HudWidgetBase {
2692
+ static typeName = "UiSelect";
2693
+ static props = {
2694
+ ...HudWidgetBase.props,
2695
+ label: { default: "" },
2696
+ /** Comma-separated choices: `"low,medium,high"`. */
2697
+ options: { default: "" },
2698
+ value: { default: "" },
2699
+ focusable: { default: true }
2700
+ };
2701
+ static signals = [...HudWidgetBase.signals, "changed"];
2702
+ label = "";
2703
+ options = "";
2704
+ value = "";
2705
+ focusable = true;
2706
+ /** Left/right (and confirm) walk the list — a select has no other gesture. */
2707
+ _activate(dir) {
2708
+ const choices = this.options.split(",").map((o) => o.trim()).filter(Boolean);
2709
+ if (choices.length === 0) return;
2710
+ const next = choices[(Math.max(0, choices.indexOf(this.value)) + (dir === 0 ? 1 : dir) + choices.length) % choices.length];
2711
+ if (!next || next === this.value) return;
2712
+ this.value = next;
2713
+ this.last = next;
2714
+ if (this.select) this.select.value = next;
2715
+ this.emit("changed", next);
2716
+ }
2717
+ select = null;
2718
+ lastOptions = "\0";
2719
+ last = "\0";
2720
+ _build() {
2721
+ const wrap = document.createElement("label");
2722
+ 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;";
2723
+ const text = document.createElement("span");
2724
+ text.textContent = this.label;
2725
+ const select = document.createElement("select");
2726
+ select.addEventListener("change", () => {
2727
+ this.value = select.value;
2728
+ this.last = this.value;
2729
+ this.emit("changed", this.value);
2730
+ });
2731
+ if (this.label) wrap.appendChild(text);
2732
+ wrap.appendChild(select);
2733
+ this.select = select;
2734
+ this.fillOptions();
2735
+ return wrap;
2736
+ }
2737
+ fillOptions() {
2738
+ const select = this.select;
2739
+ if (!select || this.options === this.lastOptions) return;
2740
+ this.lastOptions = this.options;
2741
+ select.textContent = "";
2742
+ for (const raw of this.options.split(",")) {
2743
+ const value = raw.trim();
2744
+ if (!value) continue;
2745
+ const option = document.createElement("option");
2746
+ option.value = value;
2747
+ option.textContent = value;
2748
+ select.appendChild(option);
2749
+ }
2750
+ if (this.value) select.value = this.value;
2751
+ this.last = this.value;
2752
+ }
2753
+ _sync() {
2754
+ this.fillOptions();
2755
+ if (this.select && this.value !== this.last) {
2756
+ this.last = this.value;
2757
+ this.select.value = this.value;
2758
+ }
2759
+ }
2760
+ };
2761
+ //#endregion
2031
2762
  //#region src/core/register.ts
2032
2763
  /**
2033
2764
  * Register the core node types. Call once in your game entry before loading
@@ -2044,6 +2775,11 @@ function registerCoreNodes() {
2044
2775
  registerNode(UiBanner);
2045
2776
  registerNode(UiButton);
2046
2777
  registerNode(UiDialogue);
2778
+ registerNode(UiPanel);
2779
+ registerNode(UiImage);
2780
+ registerNode(UiSlider);
2781
+ registerNode(UiToggle);
2782
+ registerNode(UiSelect);
2047
2783
  }
2048
2784
  //#endregion
2049
- export { fadeGain as C, crossfadeGains as S, SFX_PRESETS as _, HudLayer as a, MusicManager as b, UiText as c, LogManager as d, SfxEngine as f, spatialPan as g, spatialGain as h, UiDialogue as i, AudioPlayer as l, ROLLOFF_MODELS as m, Timer as n, UiBanner as o, isAudioContextAvailable as p, UiButton as r, UiBar as s, registerCoreNodes as t, Engine as u, SFX_PRESET_NAMES as v, AudioBuses as w, WebAudioMusicBackend as x, synthSfx as y };
2785
+ export { MusicManager as A, isAudioContextAvailable as C, SFX_PRESETS as D, spatialPan as E, crossfadeGains as M, fadeGain as N, SFX_PRESET_NAMES as O, AudioBuses as P, SfxEngine as S, spatialGain as T, qualityEnvironment as _, UiSlider as a, createSaveStore as b, UiButton as c, UiBanner as d, UiBar as f, Settings as g, Engine as h, UiSelect as i, WebAudioMusicBackend as j, synthSfx 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, readDeviceHints as v, ROLLOFF_MODELS as w, LogManager as x, suggestQuality as y };