incanto 0.33.0 → 0.35.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 +5 -5
  2. package/dist/2d.js +3 -3
  3. package/dist/3d.d.ts +122 -14
  4. package/dist/3d.js +5 -5
  5. package/dist/{audit-C4kmDK0o.js → audit-C5oZGAru.js} +37 -0
  6. package/dist/{behavior-CKwTCjfR.d.ts → behavior-CGSCWOHB.d.ts} +319 -22
  7. package/dist/{create-game-D2QU5x7S.js → create-game-B0Wfhi2-.js} +6 -5
  8. package/dist/{create-game-sFuTLqjD.js → create-game-BUD89Kqh.js} +20 -6
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-BgnG1Lqz.js → duplicate-C716f-97.js} +1 -1
  11. package/dist/{editor-switch-BJb-CWfA.d.ts → editor-switch-DyXEtH36.d.ts} +1 -1
  12. package/dist/editor.js +1739 -1312
  13. package/dist/env.d.ts +1 -1
  14. package/dist/{environment-presets-CQtEGogB.js → environment-presets-TAGvmM3z.js} +240 -74
  15. package/dist/{errors-1dXlIwoR.d.ts → errors-BY2kL0hv.d.ts} +1 -1
  16. package/dist/{gameplay-BpQCbABv.js → gameplay-BvhcQbfJ.js} +75 -11
  17. package/dist/gameplay.d.ts +12 -5
  18. package/dist/gameplay.js +1 -1
  19. package/dist/index.d.ts +72 -7
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-COn5fS0o.d.ts → loader-CUcj00M8.d.ts} +19 -2
  22. package/dist/{loader-Buk8Bu1h.js → loader-Mig5fY4n.js} +110 -18
  23. package/dist/net.d.ts +2 -2
  24. package/dist/net.js +3 -3
  25. package/dist/{particle-sim-CwJ5rI_P.d.ts → particle-sim-B-vZBF5R.d.ts} +1 -1
  26. package/dist/{pathfinding-DUw9mir9.d.ts → pathfinding-DgOo2KNF.d.ts} +1 -1
  27. package/dist/{physics-2d-DjXR5DMu.js → physics-2d-DqAclql-.js} +22 -9
  28. package/dist/{physics-3d-DF8npb1O.js → physics-3d-DxBH4sIF.js} +27 -11
  29. package/dist/react.d.ts +2 -2
  30. package/dist/react.js +1 -1
  31. package/dist/{register-CscIzJEO.js → register-BFLg0-_i.js} +697 -21
  32. package/dist/{register-CtI-itec.js → register-BjbPMA5B.js} +2 -2
  33. package/dist/{register-FIJtNbub.js → register-DSmIRAf7.js} +11 -5
  34. package/dist/{schema-CcoWb32N.d.ts → schema-3ywbdlrv.d.ts} +10 -0
  35. package/dist/{test-BRxLd2jH.js → test-9EokzbRd.js} +10 -10
  36. package/dist/test.d.ts +4 -4
  37. package/dist/test.js +2 -2
  38. package/dist/vite.js +1 -1
  39. package/editor/assets/{agent8-BdDP3xKW.js → agent8-D3_GWeuh.js} +1 -1
  40. package/editor/assets/{debug-DbjTyTlC.js → debug-CX0LDd1r.js} +1 -1
  41. package/editor/assets/index-CMlKFT0C.js +10773 -0
  42. package/editor/index.html +1 -1
  43. package/package.json +1 -1
  44. package/schemas/scene.schema.json +251 -6
  45. package/skills/incanto-assets.md +21 -0
  46. package/skills/incanto-behaviors-and-scripts.md +16 -0
  47. package/skills/incanto-editor.md +17 -1
  48. package/skills/incanto-gameplay-behaviors.md +6 -0
  49. package/skills/incanto-hud.md +73 -1
  50. package/skills/incanto-localization.md +132 -0
  51. package/skills/incanto-node-reference.md +78 -24
  52. package/skills/incanto-save-slots.md +152 -0
  53. package/templates-app/beacon-isle-3d/package.json +1 -1
  54. package/templates-app/tps-3d/package.json +1 -1
  55. package/templates-app/village-quest-3d/PROJECT/Context.md +16 -0
  56. package/templates-app/village-quest-3d/package.json +1 -1
  57. package/templates-app/village-quest-3d/src/village.scene.json +25 -3
  58. package/editor/assets/index-BWCudoz1.js +0 -10696
@@ -1,4 +1,4 @@
1
- import { f as Node, h as InputMap, x as Signal } from "./loader-Buk8Bu1h.js";
1
+ import { C as Signal, _ as behaviorSchema, f as Node, h as diagnose, m as InputMap } from "./loader-Mig5fY4n.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";
@@ -924,6 +924,120 @@ var SfxEngine = class {
924
924
  }
925
925
  };
926
926
  //#endregion
927
+ //#region src/core/localization.ts
928
+ /** The base locale. Every other locale falls back to it, one key at a time. */
929
+ const BASE_LOCALE = "en";
930
+ /**
931
+ * The marker that makes a string prop translatable: `"@t:hud.score"`.
932
+ *
933
+ * A prefix rather than an object wrapper, matching the `"$assetKey"` reference
934
+ * already in the engine — the prop is declared a string, so its value should
935
+ * stay a string. It also stays authorable by hand:
936
+ *
937
+ * ```json
938
+ * { "type": "UiText", "props": { "text": "@t:hud.score" } }
939
+ * ```
940
+ */
941
+ const T_PREFIX = "@t:";
942
+ /** The key a `"@t:…"` value names, or null for an ordinary string. */
943
+ function translationKey(value) {
944
+ return value.startsWith("@t:") ? value.slice(3) : null;
945
+ }
946
+ /** `Hello, {name}` + `{name: 'Ada'}` → `Hello, Ada`. Unknown slots stay put. */
947
+ function interpolate(text, params) {
948
+ if (!params) return text;
949
+ return text.replace(/\{(\w+)\}/g, (whole, name) => name in params ? String(params[name]) : whole);
950
+ }
951
+ var Localization = class {
952
+ /** Fires with the new locale after it changes — for anything that caches. */
953
+ changed = new Signal();
954
+ tables = {};
955
+ current = "en";
956
+ constructor(tables, locale) {
957
+ if (tables) this.load(tables);
958
+ if (locale) this.locale = locale;
959
+ }
960
+ /**
961
+ * Merge a scene's `strings` table in.
962
+ *
963
+ * Merged rather than replaced: a game's shared UI strings live in the scene it
964
+ * boots from, and a level that adds three lines of its own should not wipe
965
+ * them. Per-key, so a level CAN override one string.
966
+ */
967
+ load(tables) {
968
+ for (const [locale, entries] of Object.entries(tables)) {
969
+ this.tables[locale] ??= {};
970
+ const target = this.tables[locale];
971
+ for (const [key, text] of Object.entries(entries)) if (typeof text === "string") target[key] = text;
972
+ }
973
+ }
974
+ get locale() {
975
+ return this.current;
976
+ }
977
+ /**
978
+ * Switch language. Setting one nobody declared is not an error — it simply
979
+ * resolves everything through the base locale, which is the correct outcome
980
+ * for a player whose browser reports a language this game does not ship.
981
+ */
982
+ set locale(next) {
983
+ if (next === this.current || next === "") return;
984
+ this.current = next;
985
+ this.changed.emit(next);
986
+ }
987
+ /** Every locale with at least one string — what a language picker offers. */
988
+ available() {
989
+ const out = new Set(["en"]);
990
+ for (const [locale, entries] of Object.entries(this.tables)) if (Object.keys(entries).length > 0) out.add(locale);
991
+ return [...out];
992
+ }
993
+ /**
994
+ * The string for `key`: current locale, else English, else the key itself.
995
+ *
996
+ * Returning the key rather than an empty string is deliberate — a screen
997
+ * reading `menu.credits` tells an author exactly what to add, where a blank
998
+ * one tells them nothing and looks like a rendering bug.
999
+ */
1000
+ t(key, params) {
1001
+ const own = this.tables[this.current]?.[key];
1002
+ if (own !== void 0) return interpolate(own, params);
1003
+ const base = this.tables["en"]?.[key];
1004
+ if (base !== void 0) return interpolate(base, params);
1005
+ return key;
1006
+ }
1007
+ /**
1008
+ * Resolve a prop value: `"@t:key"` translates, anything else passes through.
1009
+ *
1010
+ * This is what every text-bearing widget calls, every frame. A plain string is
1011
+ * returned untouched, so a game that never localizes anything pays a
1012
+ * `startsWith` per widget per frame and nothing else.
1013
+ */
1014
+ resolve(value, params) {
1015
+ const key = translationKey(value);
1016
+ return key === null ? value : this.t(key, params);
1017
+ }
1018
+ /** Whether any locale declares this key — for the audit, not for the runtime. */
1019
+ has(key) {
1020
+ for (const entries of Object.values(this.tables)) if (entries[key] !== void 0) return true;
1021
+ return false;
1022
+ }
1023
+ };
1024
+ /**
1025
+ * The player's language, if the browser will say and the game ships it.
1026
+ *
1027
+ * A starting point only — the language setting is the real answer, exactly as
1028
+ * `suggestQuality` is a starting point for the quality tier.
1029
+ */
1030
+ function suggestLocale(available, languages) {
1031
+ const nav = globalThis.navigator;
1032
+ const wanted = languages ?? nav?.languages ?? [];
1033
+ for (const tag of wanted) {
1034
+ const base = tag.split("-")[0] ?? "";
1035
+ const hit = available.find((a) => a === tag || a === base);
1036
+ if (hit) return hit;
1037
+ }
1038
+ return "en";
1039
+ }
1040
+ //#endregion
927
1041
  //#region src/core/frame-stats.ts
928
1042
  /**
929
1043
  * Rolling window of REAL frame timestamps — the math behind `engine.stats()`
@@ -1071,6 +1185,214 @@ function storageOrNull() {
1071
1185
  }
1072
1186
  }
1073
1187
  //#endregion
1188
+ //#region src/core/save-slots.ts
1189
+ /**
1190
+ * Save slots — "continue where you left off", which no game on this engine could
1191
+ * do.
1192
+ *
1193
+ * `createSaveStore` has been here a long time and `Scene.toJSON()` round-trips a
1194
+ * live tree, so two thirds of the work existed. The missing third was that
1195
+ * BEHAVIOR state had nowhere to go: `Behavior` declared `onEnterTree`, `onReady`,
1196
+ * `onExitTree`, `update` and `fixedUpdate` and nothing for "write yourself down",
1197
+ * so `Health.current`, `ScoreKeeper.score` and every quest flag were
1198
+ * unrecoverable. Both quest templates this repo ships were RPGs you could not
1199
+ * resume.
1200
+ *
1201
+ * ## What a save is, and what it deliberately is NOT
1202
+ *
1203
+ * A save is **which scene, plus every behavior's state, keyed by node uid**. It
1204
+ * is not a snapshot of the live tree.
1205
+ *
1206
+ * That is a design decision, not a shortcut. Restoring a live tree by walking
1207
+ * paths breaks on this engine specifically:
1208
+ *
1209
+ * - `Spawner.onReady` DETACHES its prefab template from the tree (see
1210
+ * `SpawnSource.resolveTemplate`), so a captured tree and a freshly booted one
1211
+ * legitimately disagree about which nodes exist, every time.
1212
+ * Every scene with a spawner would report phantom removals.
1213
+ * - A spawned enemy carrying its own spawner has already lost ITS template, so
1214
+ * re-adding that subtree runs `onReady` against a node that is not there and
1215
+ * throws — and `onReady` is not quarantined, so it kills the whole load.
1216
+ * - Prop deltas cannot be applied with `applySchemaProps`, which resets every
1217
+ * prop to its default before writing.
1218
+ *
1219
+ * Reloading the scene from source sidesteps all three: the structure comes from
1220
+ * the file (which is authoritative and already validated), and the save carries
1221
+ * only what the file cannot know. A fresh boot detaches the same templates the
1222
+ * captured one did, so there is nothing to reconcile.
1223
+ *
1224
+ * The cost, stated plainly so nobody discovers it: **spawned enemies and
1225
+ * mid-level positions are not restored.** You resume at the scene's start with
1226
+ * your stats, inventory, unlocks and quest flags intact. That is what a
1227
+ * checkpoint save is, and it is what both quest templates need. A game that
1228
+ * wants a position saves it — `serialize()` can return anything.
1229
+ *
1230
+ * Node uids are the join key because they are the one identifier the engine
1231
+ * already promises survives a rename or a reparent.
1232
+ */
1233
+ /**
1234
+ * Every behavior in the tree that has something to say, by uid.
1235
+ *
1236
+ * A behavior with no `serialize` contributes nothing — the hook is optional in
1237
+ * exactly the way the other five are, so adding save support to a game is
1238
+ * additive and a game that never saves pays nothing.
1239
+ *
1240
+ * A node with no `uid` is skipped and reported, because state keyed by nothing
1241
+ * cannot be given back. The editor assigns a uid to every node it touches; a
1242
+ * hand-written scene may not have.
1243
+ */
1244
+ function captureBehaviors(root) {
1245
+ const state = {};
1246
+ const unaddressable = [];
1247
+ const walk = (node) => {
1248
+ const behavior = node.behavior;
1249
+ if (behavior && typeof behavior.serialize === "function") {
1250
+ const value = behavior.serialize();
1251
+ if (value !== void 0) if (node.uid) state[node.uid] = value;
1252
+ else unaddressable.push(node.getPath());
1253
+ }
1254
+ for (const child of node.children) walk(child);
1255
+ };
1256
+ walk(root);
1257
+ return {
1258
+ state,
1259
+ unaddressable
1260
+ };
1261
+ }
1262
+ /**
1263
+ * Hand each behavior its state back.
1264
+ *
1265
+ * Call this AFTER the scene has loaded and `onReady` has run: `onReady` is where
1266
+ * a behavior sets its starting values, so restoring first would be overwritten
1267
+ * by the fresh start.
1268
+ *
1269
+ * Never throws on a mismatch. A save from an older build of the game will name
1270
+ * uids that no longer exist, and refusing to load it would mean a patch that
1271
+ * moves one node deletes everyone's progress. It reports instead.
1272
+ */
1273
+ function restoreBehaviors(root, state) {
1274
+ const report = {
1275
+ missing: [],
1276
+ skipped: [],
1277
+ restored: 0
1278
+ };
1279
+ for (const [uid, value] of Object.entries(state)) {
1280
+ const node = root.getNodeByUid(uid);
1281
+ if (!node) {
1282
+ report.missing.push(uid);
1283
+ continue;
1284
+ }
1285
+ const behavior = node.behavior;
1286
+ if (!behavior || typeof behavior.deserialize !== "function") {
1287
+ report.skipped.push(uid);
1288
+ continue;
1289
+ }
1290
+ try {
1291
+ behavior.deserialize(value);
1292
+ report.restored += 1;
1293
+ } catch {
1294
+ report.skipped.push(uid);
1295
+ }
1296
+ }
1297
+ return report;
1298
+ }
1299
+ /** Slot ids, so a "3 save slots" menu is a list and not a naming convention. */
1300
+ const INDEX_KEY = "slots";
1301
+ /**
1302
+ * The slot layer: a named list of saves over the existing store.
1303
+ *
1304
+ * Slot ids are yours (`'auto'`, `'1'`, `'2'`, `'3'`) — the engine does not
1305
+ * decide how many you have or whether one of them autosaves.
1306
+ */
1307
+ var SaveSlots = class {
1308
+ store;
1309
+ constructor(namespace = "saves", store) {
1310
+ this.store = store ?? createSaveStore(namespace);
1311
+ }
1312
+ /** Slot ids that have a save, newest first. */
1313
+ list() {
1314
+ const rows = this.store.get(INDEX_KEY, []).map((id) => ({
1315
+ id,
1316
+ slot: this.read(id)
1317
+ })).filter((row) => row.slot !== null);
1318
+ rows.sort((a, b) => b.slot.savedAt - a.slot.savedAt);
1319
+ return rows.map((row) => row.id);
1320
+ }
1321
+ /** Every slot with its contents — what a load menu actually renders. */
1322
+ all() {
1323
+ return this.list().map((id) => ({
1324
+ id,
1325
+ ...this.read(id)
1326
+ }));
1327
+ }
1328
+ read(id) {
1329
+ const raw = this.store.get(key(id), null);
1330
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
1331
+ const slot = raw;
1332
+ if (typeof slot.scene !== "string" || typeof slot.state !== "object" || slot.state === null) return null;
1333
+ return {
1334
+ scene: slot.scene,
1335
+ state: slot.state,
1336
+ label: typeof slot.label === "string" ? slot.label : "",
1337
+ savedAt: typeof slot.savedAt === "number" ? slot.savedAt : 0,
1338
+ playtime: typeof slot.playtime === "number" ? slot.playtime : 0,
1339
+ data: slot.data ?? {}
1340
+ };
1341
+ }
1342
+ /**
1343
+ * Write a slot.
1344
+ *
1345
+ * `savedAt` is a parameter rather than `Date.now()` so a test can assert an
1346
+ * ordering without sleeping, and so a game that wants server time can use it.
1347
+ */
1348
+ write(id, slot) {
1349
+ const full = {
1350
+ scene: slot.scene,
1351
+ state: slot.state,
1352
+ label: slot.label ?? "",
1353
+ savedAt: slot.savedAt ?? 0,
1354
+ playtime: slot.playtime ?? 0,
1355
+ data: slot.data ?? {}
1356
+ };
1357
+ this.store.set(key(id), full);
1358
+ const ids = this.store.get(INDEX_KEY, []);
1359
+ if (!ids.includes(id)) this.store.set(INDEX_KEY, [...ids, id]);
1360
+ }
1361
+ remove(id) {
1362
+ this.store.remove(key(id));
1363
+ this.store.set(INDEX_KEY, this.store.get(INDEX_KEY, []).filter((other) => other !== id));
1364
+ }
1365
+ /** Wipe every slot — a "delete all data" button, and test isolation. */
1366
+ clear() {
1367
+ for (const id of this.store.get(INDEX_KEY, [])) this.store.remove(key(id));
1368
+ this.store.set(INDEX_KEY, []);
1369
+ }
1370
+ };
1371
+ function key(id) {
1372
+ return `slot:${id}`;
1373
+ }
1374
+ /**
1375
+ * Behaviors in this tree that hold state and cannot save it — the audit's
1376
+ * question, answerable before a player loses an hour.
1377
+ *
1378
+ * A behavior with props but no `serialize` is not necessarily wrong (an
1379
+ * `Oscillate` derives everything from time), so this reports rather than warns,
1380
+ * and the caller decides.
1381
+ */
1382
+ function behaviorsWithoutSave(root) {
1383
+ const out = [];
1384
+ const walk = (node) => {
1385
+ const behavior = node.behavior;
1386
+ const name = node.script?.name;
1387
+ if (behavior && typeof name === "string" && typeof behavior.serialize !== "function") {
1388
+ if (Object.keys(behaviorSchema(name)).length > 0) out.push(`${node.getPath()} (${name})`);
1389
+ }
1390
+ for (const child of node.children) walk(child);
1391
+ };
1392
+ walk(root);
1393
+ return out;
1394
+ }
1395
+ //#endregion
1074
1396
  //#region src/core/settings.ts
1075
1397
  const DEFAULTS = {
1076
1398
  master: 1,
@@ -1080,7 +1402,8 @@ const DEFAULTS = {
1080
1402
  quality: "high",
1081
1403
  sensitivity: 1,
1082
1404
  invertY: false,
1083
- reduceMotion: false
1405
+ reduceMotion: false,
1406
+ language: "en"
1084
1407
  };
1085
1408
  const TIERS = [
1086
1409
  "low",
@@ -1146,6 +1469,19 @@ var Settings = class {
1146
1469
  for (const key of Object.keys(DEFAULTS)) this.set(key, DEFAULTS[key]);
1147
1470
  }
1148
1471
  /**
1472
+ * Apply the saved language, and keep it in step both ways.
1473
+ *
1474
+ * The same shape as `bindAudio`: a game that writes
1475
+ * `engine.locale.locale = 'ko'` from an options menu has already persisted it
1476
+ * without knowing this class exists. Returns the unsubscribe.
1477
+ */
1478
+ bindLocale(locale) {
1479
+ locale.locale = this.values.language;
1480
+ return locale.changed.connect((next) => {
1481
+ this.set("language", next);
1482
+ });
1483
+ }
1484
+ /**
1149
1485
  * Push the saved volumes into the engine's buses, and keep writing them back
1150
1486
  * whenever anything changes them.
1151
1487
  *
@@ -1267,6 +1603,11 @@ var Engine = class {
1267
1603
  * `bindAudio` makes the volumes write themselves.
1268
1604
  */
1269
1605
  settings = new Settings();
1606
+ /**
1607
+ * Translations, and the locale in force. English is the base and the default;
1608
+ * anything a locale does not declare falls back to it silently.
1609
+ */
1610
+ locale = new Localization();
1270
1611
  /** Low-latency procedural-SFX player (WebAudio, headless-safe). AudioPlayer
1271
1612
  * presets play through it; games may call `engine.sfx.play(...)` directly. */
1272
1613
  sfx = new SfxEngine();
@@ -1340,6 +1681,7 @@ var Engine = class {
1340
1681
  this._time = 0;
1341
1682
  this._unscaledTime = 0;
1342
1683
  if (scene.input) this.input.declare(scene.input);
1684
+ if (scene.strings) this.locale.load(scene.strings);
1343
1685
  this.sceneChanged.emit(scene);
1344
1686
  }
1345
1687
  start() {
@@ -1377,6 +1719,41 @@ var Engine = class {
1377
1719
  * both — so a tool can list them and put them back. Walks on demand; there is
1378
1720
  * no per-frame bookkeeping for this.
1379
1721
  */
1722
+ /**
1723
+ * The string for `key` in the current locale — English if this locale does not
1724
+ * declare it, the key itself if nothing does.
1725
+ *
1726
+ * The short form of `engine.locale.t`, because a behavior reaching for a
1727
+ * translated string is the common case:
1728
+ * `banner.show(this.engine.t('wave.start', { n: wave }))`.
1729
+ */
1730
+ t(key, params) {
1731
+ return this.locale.t(key, params);
1732
+ }
1733
+ /**
1734
+ * Every behavior's state, ready to put in a save slot.
1735
+ *
1736
+ * Pairs with `restoreState` after the scene has RELOADED — see
1737
+ * core/save-slots.ts for why a save carries state rather than a tree
1738
+ * snapshot.
1739
+ */
1740
+ captureState() {
1741
+ const root = this.scene?.root;
1742
+ if (!root) return {};
1743
+ const { state, unaddressable } = captureBehaviors(root);
1744
+ for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
1745
+ return state;
1746
+ }
1747
+ /** Give it back, after the scene is loaded and onReady has run. */
1748
+ restoreState(state) {
1749
+ const root = this.scene?.root;
1750
+ if (!root) return {
1751
+ missing: Object.keys(state),
1752
+ skipped: [],
1753
+ restored: 0
1754
+ };
1755
+ return restoreBehaviors(root, state);
1756
+ }
1380
1757
  erroredNodes() {
1381
1758
  const out = [];
1382
1759
  const walk = (node) => {
@@ -1727,10 +2104,17 @@ var HudLayer = class extends Node {
1727
2104
  static typeName = "HudLayer";
1728
2105
  static props = {
1729
2106
  zIndex: { default: 100 },
1730
- visible: { default: true }
2107
+ visible: { default: true },
2108
+ /**
2109
+ * Arrow keys / d-pad move focus between the focusable widgets under this
2110
+ * layer, Enter / A activates. OFF by default — a game whose HUD has a
2111
+ * button must not lose its arrow keys the moment one exists.
2112
+ */
2113
+ focusNavigation: { default: false }
1731
2114
  };
1732
2115
  zIndex = 100;
1733
2116
  visible = true;
2117
+ focusNavigation = false;
1734
2118
  /** @internal root overlay element (null headless). */
1735
2119
  _element = null;
1736
2120
  slots = /* @__PURE__ */ new Map();
@@ -1771,6 +2155,66 @@ var HudLayer = class extends Node {
1771
2155
  this._mount();
1772
2156
  this._element.style.display = this.visible ? "" : "none";
1773
2157
  }
2158
+ if (this.focusNavigation) this.stepFocus();
2159
+ }
2160
+ /**
2161
+ * Arrow keys / d-pad move the focus, Enter / A activates it.
2162
+ *
2163
+ * Off by default: a game whose HUD has a button must not lose its arrow keys
2164
+ * to a menu the moment one exists. Turn it on for the screens that ARE menus
2165
+ * (`"focusNavigation": true` on the pause panel's layer), and off again when
2166
+ * play resumes.
2167
+ */
2168
+ stepFocus() {
2169
+ const engine = this.tree?.engine;
2170
+ if (!engine) return;
2171
+ const items = this.focusables();
2172
+ if (items.length === 0) return;
2173
+ const input = engine.input;
2174
+ const down = input.keyJustPressed("ArrowDown") || input.keyJustPressed("Pad13");
2175
+ const up = input.keyJustPressed("ArrowUp") || input.keyJustPressed("Pad12");
2176
+ const left = input.keyJustPressed("ArrowLeft") || input.keyJustPressed("Pad14");
2177
+ const right = input.keyJustPressed("ArrowRight") || input.keyJustPressed("Pad15");
2178
+ const confirm = input.keyJustPressed("Enter") || input.keyJustPressed("Pad0");
2179
+ let index = items.findIndex((w) => w._focused);
2180
+ if (index === -1 && (down || up || confirm)) index = 0;
2181
+ else if (down) index = (index + 1) % items.length;
2182
+ else if (up) index = (index - 1 + items.length) % items.length;
2183
+ if (index !== -1) {
2184
+ for (const w of items) {
2185
+ const next = w === items[index];
2186
+ if (next !== w._focused) {
2187
+ w._focused = next;
2188
+ w._paintFocus();
2189
+ }
2190
+ }
2191
+ const focused = items[index];
2192
+ if (focused) {
2193
+ if (confirm) focused._activate(0);
2194
+ else if (left) focused._activate(-1);
2195
+ else if (right) focused._activate(1);
2196
+ }
2197
+ }
2198
+ }
2199
+ /** Focusable widgets under this layer, in tree order. */
2200
+ focusables() {
2201
+ const out = [];
2202
+ const walk = (node) => {
2203
+ if (node instanceof HudWidgetBase && node.focusable && node.visible) out.push(node);
2204
+ for (const child of node.children) walk(child);
2205
+ };
2206
+ walk(this);
2207
+ return out;
2208
+ }
2209
+ /** Move focus to a widget by hand — opening a menu should start somewhere. */
2210
+ focus(widget) {
2211
+ for (const w of this.focusables()) {
2212
+ const next = w === widget;
2213
+ if (next !== w._focused) {
2214
+ w._focused = next;
2215
+ w._paintFocus();
2216
+ }
2217
+ }
1774
2218
  }
1775
2219
  /** @internal Widgets mount into per-anchor flex columns. */
1776
2220
  _slot(anchor) {
@@ -1785,6 +2229,22 @@ var HudLayer = class extends Node {
1785
2229
  return slot;
1786
2230
  }
1787
2231
  };
2232
+ /**
2233
+ * The widget an element belongs to.
2234
+ *
2235
+ * A pick lands on whatever div is under the cursor — usually a CHILD of the
2236
+ * widget (an icon inside a slot), so the walk upward is the point.
2237
+ */
2238
+ const widgetOf = /* @__PURE__ */ new WeakMap();
2239
+ function widgetFromElement(el) {
2240
+ for (let n = el; n; n = n.parentElement) {
2241
+ const found = widgetOf.get(n);
2242
+ if (found) return found;
2243
+ }
2244
+ return null;
2245
+ }
2246
+ /** The drag in flight, if any. One pointer, one drag. */
2247
+ let dragging = null;
1788
2248
  /** Shared plumbing: mount into the parent HudLayer's anchor slot. */
1789
2249
  var HudWidgetBase = class extends Node {
1790
2250
  static props = {
@@ -1802,10 +2262,46 @@ var HudWidgetBase = class extends Node {
1802
2262
  "bottomRight"
1803
2263
  ]
1804
2264
  },
1805
- visible: { default: true }
2265
+ visible: { default: true },
2266
+ /**
2267
+ * Can menu navigation land on this? Interactive widgets default to true.
2268
+ *
2269
+ * A menu you can only click is not playable on a controller, and "add
2270
+ * gamepad support" is not a thing a JSON scene could express at all.
2271
+ */
2272
+ focusable: { default: false },
2273
+ /**
2274
+ * Can the player pick this up and drop it somewhere? An inventory item is
2275
+ * a `draggable` widget; a slot is a `dropTarget`.
2276
+ */
2277
+ draggable: { default: false },
2278
+ /** Can something be dropped ON this? */
2279
+ dropTarget: { default: false }
1806
2280
  };
2281
+ static signals = [
2282
+ "dragStarted",
2283
+ "dragCancelled",
2284
+ "droppedOn",
2285
+ "dropped"
2286
+ ];
1807
2287
  anchor = "topLeft";
1808
2288
  visible = true;
2289
+ focusable = false;
2290
+ draggable = false;
2291
+ dropTarget = false;
2292
+ /** @internal Set by the owning HudLayer while this widget has focus. */
2293
+ _focused = false;
2294
+ /**
2295
+ * @internal Confirm (`dir` 0) or nudge (-1 left / +1 right) — what pressing
2296
+ * A or an arrow ON this widget means. Default: nothing.
2297
+ */
2298
+ _activate(_dir) {}
2299
+ /** @internal Draw the focus ring. Overridable if a widget wants its own. */
2300
+ _paintFocus() {
2301
+ if (!this._element) return;
2302
+ this._element.style.outline = this._focused ? "2px solid #6ee7dc" : "";
2303
+ this._element.style.outlineOffset = this._focused ? "2px" : "";
2304
+ }
1809
2305
  /** @internal */
1810
2306
  _element = null;
1811
2307
  layer() {
@@ -1830,8 +2326,45 @@ var HudWidgetBase = class extends Node {
1830
2326
  const host = this.host() ?? this.layer()?._slot(this.anchor);
1831
2327
  if (!host) return;
1832
2328
  this._element = this._build();
2329
+ widgetOf.set(this._element, this);
2330
+ if (this.draggable || this.dropTarget) this._wireDrag(this._element);
1833
2331
  host.appendChild(this._element);
1834
2332
  }
2333
+ /**
2334
+ * Drag and drop, on the DOM the engine already builds.
2335
+ *
2336
+ * An inventory is the one screen where "click it" is not enough, and every
2337
+ * game that wanted one dropped out of scene JSON to hand-roll pointer
2338
+ * handlers. The whole gesture is four signals and no new node type: a
2339
+ * `draggable` widget emits `dragStarted` and, if it lands on one,
2340
+ * `droppedOn(target)`; a `dropTarget` emits `dropped(source)`. Who owns the
2341
+ * ITEM is the game's business — this reports the gesture, not a model.
2342
+ */
2343
+ _wireDrag(el) {
2344
+ el.style.touchAction = "none";
2345
+ if (this.draggable) {
2346
+ el.style.cursor = "grab";
2347
+ el.addEventListener("pointerdown", (event) => {
2348
+ if (!this.draggable) return;
2349
+ event.preventDefault();
2350
+ el.setPointerCapture?.(event.pointerId);
2351
+ dragging = this;
2352
+ el.style.opacity = "0.6";
2353
+ this.emit("dragStarted", this);
2354
+ });
2355
+ el.addEventListener("pointerup", (event) => {
2356
+ if (dragging !== this) return;
2357
+ el.style.opacity = "";
2358
+ el.releasePointerCapture?.(event.pointerId);
2359
+ const target = widgetFromElement(document.elementFromPoint?.(event.clientX, event.clientY) ?? null);
2360
+ dragging = null;
2361
+ if (target && target !== this && target.dropTarget) {
2362
+ this.emit("droppedOn", target);
2363
+ target.emit("dropped", this);
2364
+ } else this.emit("dragCancelled", this);
2365
+ });
2366
+ }
2367
+ }
1835
2368
  /** The nearest ancestor that holds widgets itself (a UiPanel). */
1836
2369
  host() {
1837
2370
  for (let p = this.parent; p; p = p.parent) {
@@ -1854,6 +2387,18 @@ var HudWidgetBase = class extends Node {
1854
2387
  this._sync();
1855
2388
  }
1856
2389
  _sync() {}
2390
+ /**
2391
+ * Resolve a text prop through the engine's locale.
2392
+ *
2393
+ * Called every frame from `_sync`, which is what makes switching language
2394
+ * repaint with nothing to invalidate: widgets already re-read their props, so
2395
+ * the new string simply appears on the next frame. A plain string costs one
2396
+ * `startsWith` and is returned untouched.
2397
+ */
2398
+ _t(value) {
2399
+ const locale = this.tree?.engine?.locale;
2400
+ return locale ? locale.resolve(value) : value;
2401
+ }
1857
2402
  };
1858
2403
  /** A text line (score, timer, hints). Set `.text` from behaviors. */
1859
2404
  var UiText = class extends HudWidgetBase {
@@ -1876,9 +2421,10 @@ var UiText = class extends HudWidgetBase {
1876
2421
  return el;
1877
2422
  }
1878
2423
  _sync() {
1879
- if (this.text !== this.last && this._element) {
1880
- this.last = this.text;
1881
- this._element.textContent = this.text;
2424
+ const shown = this._t(this.text);
2425
+ if (shown !== this.last && this._element) {
2426
+ this.last = shown;
2427
+ this._element.textContent = shown;
1882
2428
  }
1883
2429
  }
1884
2430
  };
@@ -1907,6 +2453,8 @@ var UiBar = class extends HudWidgetBase {
1907
2453
  background = "rgba(0,0,0,0.5)";
1908
2454
  label = "";
1909
2455
  fill = null;
2456
+ tag = null;
2457
+ lastLabel = "\0";
1910
2458
  lastRatio = -1;
1911
2459
  /** Current fill ratio 0..1 (what the bar shows). */
1912
2460
  get ratio() {
@@ -1917,8 +2465,8 @@ var UiBar = class extends HudWidgetBase {
1917
2465
  wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
1918
2466
  if (this.label) {
1919
2467
  const tag = document.createElement("span");
1920
- tag.textContent = this.label;
1921
2468
  tag.style.cssText = "color:#fff;font-size:12px;text-shadow:0 1px 3px rgba(0,0,0,.7);";
2469
+ this.tag = tag;
1922
2470
  wrap.appendChild(tag);
1923
2471
  }
1924
2472
  const track = document.createElement("div");
@@ -1930,6 +2478,11 @@ var UiBar = class extends HudWidgetBase {
1930
2478
  return wrap;
1931
2479
  }
1932
2480
  _sync() {
2481
+ const label = this._t(this.label);
2482
+ if (label !== this.lastLabel && this.tag) {
2483
+ this.lastLabel = label;
2484
+ this.tag.textContent = label;
2485
+ }
1933
2486
  const r = this.ratio;
1934
2487
  if (r === this.lastRatio || !this.fill) return;
1935
2488
  this.lastRatio = r;
@@ -2007,6 +2560,7 @@ var UiBanner = class extends HudWidgetBase {
2007
2560
  }
2008
2561
  if (!this.current && this.queue.length > 0) {
2009
2562
  this.current = this.queue.shift();
2563
+ this.current.text = this._t(this.current.text);
2010
2564
  this.remaining = this.current.seconds;
2011
2565
  if (this._element) {
2012
2566
  this._element.textContent = this.current.text;
@@ -2035,13 +2589,19 @@ var UiButton = class extends HudWidgetBase {
2035
2589
  size: { default: 16 },
2036
2590
  color: { default: "#ffffff" },
2037
2591
  background: { default: "rgba(255,255,255,0.14)" },
2038
- disabled: { default: false }
2592
+ disabled: { default: false },
2593
+ focusable: { default: true }
2039
2594
  };
2040
2595
  text = "OK";
2041
2596
  size = 16;
2042
2597
  color = "#ffffff";
2043
2598
  background = "rgba(255,255,255,0.14)";
2044
2599
  disabled = false;
2600
+ focusable = true;
2601
+ /** Enter / A on a focused button is a press. */
2602
+ _activate(_dir) {
2603
+ this.press();
2604
+ }
2045
2605
  lastText = "";
2046
2606
  /** Programmatic press — same path as a click (tests, gamepad menus). */
2047
2607
  press() {
@@ -2069,9 +2629,10 @@ var UiButton = class extends HudWidgetBase {
2069
2629
  }
2070
2630
  _sync() {
2071
2631
  if (!this._element) return;
2072
- if (this.text !== this.lastText) {
2073
- this.lastText = this.text;
2074
- this._element.textContent = this.text;
2632
+ const shown = this._t(this.text);
2633
+ if (shown !== this.lastText) {
2634
+ this.lastText = shown;
2635
+ this._element.textContent = shown;
2075
2636
  }
2076
2637
  this._element.disabled = this.disabled;
2077
2638
  this._element.style.opacity = this.disabled ? "0.45" : "1";
@@ -2196,6 +2757,9 @@ var UiDialogue = class extends HudWidgetBase {
2196
2757
  super.update(dt);
2197
2758
  if (!this.current && this.queue.length > 0) {
2198
2759
  this.current = this.queue.shift();
2760
+ this.current.text = this._t(this.current.text);
2761
+ this.current.speaker = this._t(this.current.speaker);
2762
+ if (this.current.choices) this.current.choices = this.current.choices.map((c) => this._t(c));
2199
2763
  this.revealed = this.charsPerSecond <= 0 ? this.current.text.length : 0;
2200
2764
  this.emit("lineShown", this.current.text);
2201
2765
  if (this._element) {
@@ -2419,9 +2983,10 @@ var UiSlider = class extends HudWidgetBase {
2419
2983
  max: { default: 1 },
2420
2984
  step: { default: .01 },
2421
2985
  width: { default: 180 },
2422
- color: { default: "#6ee7dc" }
2986
+ color: { default: "#6ee7dc" },
2987
+ focusable: { default: true }
2423
2988
  };
2424
- static signals = ["changed"];
2989
+ static signals = [...HudWidgetBase.signals, "changed"];
2425
2990
  label = "";
2426
2991
  value = .5;
2427
2992
  min = 0;
@@ -2429,6 +2994,17 @@ var UiSlider = class extends HudWidgetBase {
2429
2994
  step = .01;
2430
2995
  width = 180;
2431
2996
  color = "#6ee7dc";
2997
+ focusable = true;
2998
+ /** Left/right nudge by one step — the only sane thing a d-pad can mean here. */
2999
+ _activate(dir) {
3000
+ if (dir === 0) return;
3001
+ const next = Math.min(this.max, Math.max(this.min, this.value + dir * this.step));
3002
+ if (next === this.value) return;
3003
+ this.value = next;
3004
+ this.last = next;
3005
+ if (this.input) this.input.value = String(next);
3006
+ this.emit("changed", next);
3007
+ }
2432
3008
  input = null;
2433
3009
  text = null;
2434
3010
  last = NaN;
@@ -2470,11 +3046,22 @@ var UiToggle = class extends HudWidgetBase {
2470
3046
  static props = {
2471
3047
  ...HudWidgetBase.props,
2472
3048
  label: { default: "" },
2473
- value: { default: false }
3049
+ value: { default: false },
3050
+ focusable: { default: true }
2474
3051
  };
2475
- static signals = ["changed"];
3052
+ static signals = [...HudWidgetBase.signals, "changed"];
2476
3053
  label = "";
2477
3054
  value = false;
3055
+ focusable = true;
3056
+ /** Confirm flips it; left/right set it explicitly, which reads better on a pad. */
3057
+ _activate(dir) {
3058
+ const next = dir === 0 ? !this.value : dir > 0;
3059
+ if (next === this.value) return;
3060
+ this.value = next;
3061
+ this.last = next;
3062
+ if (this.input) this.input.checked = next;
3063
+ this.emit("changed", next);
3064
+ }
2478
3065
  input = null;
2479
3066
  last = null;
2480
3067
  _build() {
@@ -2510,20 +3097,35 @@ var UiSelect = class extends HudWidgetBase {
2510
3097
  label: { default: "" },
2511
3098
  /** Comma-separated choices: `"low,medium,high"`. */
2512
3099
  options: { default: "" },
2513
- value: { default: "" }
3100
+ value: { default: "" },
3101
+ focusable: { default: true }
2514
3102
  };
2515
- static signals = ["changed"];
3103
+ static signals = [...HudWidgetBase.signals, "changed"];
2516
3104
  label = "";
2517
3105
  options = "";
2518
3106
  value = "";
3107
+ focusable = true;
3108
+ /** Left/right (and confirm) walk the list — a select has no other gesture. */
3109
+ _activate(dir) {
3110
+ const choices = this.options.split(",").map((o) => o.trim()).filter(Boolean);
3111
+ if (choices.length === 0) return;
3112
+ const next = choices[(Math.max(0, choices.indexOf(this.value)) + (dir === 0 ? 1 : dir) + choices.length) % choices.length];
3113
+ if (!next || next === this.value) return;
3114
+ this.value = next;
3115
+ this.last = next;
3116
+ if (this.select) this.select.value = next;
3117
+ this.emit("changed", next);
3118
+ }
2519
3119
  select = null;
3120
+ labelEl = null;
3121
+ lastLabel = "x";
2520
3122
  lastOptions = "\0";
2521
3123
  last = "\0";
2522
3124
  _build() {
2523
3125
  const wrap = document.createElement("label");
2524
3126
  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;";
2525
3127
  const text = document.createElement("span");
2526
- text.textContent = this.label;
3128
+ this.labelEl = text;
2527
3129
  const select = document.createElement("select");
2528
3130
  select.addEventListener("change", () => {
2529
3131
  this.value = select.value;
@@ -2536,6 +3138,10 @@ var UiSelect = class extends HudWidgetBase {
2536
3138
  this.fillOptions();
2537
3139
  return wrap;
2538
3140
  }
3141
+ /** Override point: what an option READS as, when it differs from its value. */
3142
+ _optionLabel(value) {
3143
+ return value;
3144
+ }
2539
3145
  fillOptions() {
2540
3146
  const select = this.select;
2541
3147
  if (!select || this.options === this.lastOptions) return;
@@ -2546,13 +3152,18 @@ var UiSelect = class extends HudWidgetBase {
2546
3152
  if (!value) continue;
2547
3153
  const option = document.createElement("option");
2548
3154
  option.value = value;
2549
- option.textContent = value;
3155
+ option.textContent = this._optionLabel(value);
2550
3156
  select.appendChild(option);
2551
3157
  }
2552
3158
  if (this.value) select.value = this.value;
2553
3159
  this.last = this.value;
2554
3160
  }
2555
3161
  _sync() {
3162
+ const label = this._t(this.label);
3163
+ if (label !== this.lastLabel && this.labelEl) {
3164
+ this.lastLabel = label;
3165
+ this.labelEl.textContent = label;
3166
+ }
2556
3167
  this.fillOptions();
2557
3168
  if (this.select && this.value !== this.last) {
2558
3169
  this.last = this.value;
@@ -2560,6 +3171,70 @@ var UiSelect = class extends HudWidgetBase {
2560
3171
  }
2561
3172
  }
2562
3173
  };
3174
+ /** Endonyms: `ko` must read as the Korean word to someone who cannot read `Korean`. */
3175
+ const ENDONYMS = {
3176
+ en: "English",
3177
+ ko: "한국어",
3178
+ ja: "日本語",
3179
+ "zh-CN": "简体中文",
3180
+ "zh-TW": "繁體中文",
3181
+ es: "Español",
3182
+ fr: "Français",
3183
+ de: "Deutsch",
3184
+ pt: "Português",
3185
+ ru: "Русский",
3186
+ it: "Italiano",
3187
+ th: "ไทย",
3188
+ vi: "Tiếng Việt",
3189
+ id: "Bahasa Indonesia"
3190
+ };
3191
+ /**
3192
+ * The language picker, done — because every game needs the same one.
3193
+ *
3194
+ * A plain `UiSelect` bound to the locale would take a behavior, a signal
3195
+ * connection, and the knowledge that `Settings` persists it: three things an
3196
+ * agent has to get right to ship a feature the engine already has. This is one
3197
+ * node.
3198
+ *
3199
+ * ```json
3200
+ * { "name": "Language", "type": "UiLanguageSelect",
3201
+ * "props": { "label": "@t:settings.language" } }
3202
+ * ```
3203
+ *
3204
+ * Options are the locales the SCENE declares, so a game shipping only English
3205
+ * shows only English and the widget quietly costs nothing. Each is labeled with
3206
+ * its own endonym, because a player who cannot read the language currently on
3207
+ * screen still has to be able to find theirs.
3208
+ */
3209
+ var UiLanguageSelect = class extends UiSelect {
3210
+ static typeName = "UiLanguageSelect";
3211
+ static props = {
3212
+ ...UiSelect.props,
3213
+ label: { default: "Language" }
3214
+ };
3215
+ label = "Language";
3216
+ /** Last locale we PUSHED into `value`, to tell an engine change from a player one. */
3217
+ lastLocale = "";
3218
+ _sync() {
3219
+ const locale = this.tree?.engine?.locale;
3220
+ if (locale) {
3221
+ const available = locale.available();
3222
+ const options = available.join(",");
3223
+ if (options !== this.options) this.options = options;
3224
+ if (locale.locale !== this.lastLocale) {
3225
+ this.lastLocale = locale.locale;
3226
+ this.value = locale.locale;
3227
+ } else if (this.value !== locale.locale && available.includes(this.value)) {
3228
+ locale.locale = this.value;
3229
+ this.lastLocale = this.value;
3230
+ }
3231
+ }
3232
+ super._sync();
3233
+ }
3234
+ _optionLabel(value) {
3235
+ return ENDONYMS[value] ?? value;
3236
+ }
3237
+ };
2563
3238
  //#endregion
2564
3239
  //#region src/core/register.ts
2565
3240
  /**
@@ -2582,6 +3257,7 @@ function registerCoreNodes() {
2582
3257
  registerNode(UiSlider);
2583
3258
  registerNode(UiToggle);
2584
3259
  registerNode(UiSelect);
3260
+ registerNode(UiLanguageSelect);
2585
3261
  }
2586
3262
  //#endregion
2587
- 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 };
3263
+ export { translationKey as A, WebAudioMusicBackend as B, restoreBehaviors as C, Localization as D, BASE_LOCALE as E, spatialPan as F, fadeGain as H, SFX_PRESETS as I, SFX_PRESET_NAMES as L, isAudioContextAvailable as M, ROLLOFF_MODELS as N, T_PREFIX as O, spatialGain as P, synthSfx as R, captureBehaviors as S, LogManager as T, AudioBuses as U, crossfadeGains as V, qualityEnvironment as _, UiSlider as a, SaveSlots as b, UiButton as c, UiBanner as d, UiBar as f, Settings as g, Engine as h, UiSelect as i, SfxEngine as j, suggestLocale 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, createSaveStore as w, behaviorsWithoutSave as x, suggestQuality as y, MusicManager as z };