incanto 0.34.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-CtPScDMo.d.ts → behavior-CGSCWOHB.d.ts} +310 -22
  7. package/dist/{create-game-CFNqoqOX.js → create-game-B0Wfhi2-.js} +6 -5
  8. package/dist/{create-game-QTfghzwM.js → create-game-BUD89Kqh.js} +20 -6
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-DlOvknDO.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-B6WiUsaO.js → environment-presets-TAGvmM3z.js} +240 -74
  15. package/dist/{errors-1dXlIwoR.d.ts → errors-BY2kL0hv.d.ts} +1 -1
  16. package/dist/{gameplay-BxFtmfSe.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 +22 -7
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-DmXfj6Uv.d.ts → loader-CUcj00M8.d.ts} +19 -2
  22. package/dist/{loader-BwR0DxeM.js → loader-Mig5fY4n.js} +97 -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-DRCe89SI.d.ts → pathfinding-DgOo2KNF.d.ts} +1 -1
  27. package/dist/{physics-2d-BuyAwsW5.js → physics-2d-DqAclql-.js} +22 -9
  28. package/dist/{physics-3d-C8Kn_Nyb.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-G3edsjJ2.js → register-BFLg0-_i.js} +490 -12
  32. package/dist/{register-OodmuUMK.js → register-BjbPMA5B.js} +2 -2
  33. package/dist/{register-BLPC10Mj.js → register-DSmIRAf7.js} +11 -5
  34. package/dist/{schema-CcoWb32N.d.ts → schema-3ywbdlrv.d.ts} +10 -0
  35. package/dist/{test-C4B5znap.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-BTODHtdM.js → agent8-D3_GWeuh.js} +1 -1
  40. package/editor/assets/{debug-CJEz4EwC.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 +109 -0
  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 +16 -0
  50. package/skills/incanto-localization.md +132 -0
  51. package/skills/incanto-node-reference.md +33 -18
  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-BYCso8Bf.js +0 -10696
@@ -1,4 +1,4 @@
1
- import { f as Node, h as InputMap, x as Signal } from "./loader-BwR0DxeM.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) => {
@@ -2010,6 +2387,18 @@ var HudWidgetBase = class extends Node {
2010
2387
  this._sync();
2011
2388
  }
2012
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
+ }
2013
2402
  };
2014
2403
  /** A text line (score, timer, hints). Set `.text` from behaviors. */
2015
2404
  var UiText = class extends HudWidgetBase {
@@ -2032,9 +2421,10 @@ var UiText = class extends HudWidgetBase {
2032
2421
  return el;
2033
2422
  }
2034
2423
  _sync() {
2035
- if (this.text !== this.last && this._element) {
2036
- this.last = this.text;
2037
- 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;
2038
2428
  }
2039
2429
  }
2040
2430
  };
@@ -2063,6 +2453,8 @@ var UiBar = class extends HudWidgetBase {
2063
2453
  background = "rgba(0,0,0,0.5)";
2064
2454
  label = "";
2065
2455
  fill = null;
2456
+ tag = null;
2457
+ lastLabel = "\0";
2066
2458
  lastRatio = -1;
2067
2459
  /** Current fill ratio 0..1 (what the bar shows). */
2068
2460
  get ratio() {
@@ -2073,8 +2465,8 @@ var UiBar = class extends HudWidgetBase {
2073
2465
  wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
2074
2466
  if (this.label) {
2075
2467
  const tag = document.createElement("span");
2076
- tag.textContent = this.label;
2077
2468
  tag.style.cssText = "color:#fff;font-size:12px;text-shadow:0 1px 3px rgba(0,0,0,.7);";
2469
+ this.tag = tag;
2078
2470
  wrap.appendChild(tag);
2079
2471
  }
2080
2472
  const track = document.createElement("div");
@@ -2086,6 +2478,11 @@ var UiBar = class extends HudWidgetBase {
2086
2478
  return wrap;
2087
2479
  }
2088
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
+ }
2089
2486
  const r = this.ratio;
2090
2487
  if (r === this.lastRatio || !this.fill) return;
2091
2488
  this.lastRatio = r;
@@ -2163,6 +2560,7 @@ var UiBanner = class extends HudWidgetBase {
2163
2560
  }
2164
2561
  if (!this.current && this.queue.length > 0) {
2165
2562
  this.current = this.queue.shift();
2563
+ this.current.text = this._t(this.current.text);
2166
2564
  this.remaining = this.current.seconds;
2167
2565
  if (this._element) {
2168
2566
  this._element.textContent = this.current.text;
@@ -2231,9 +2629,10 @@ var UiButton = class extends HudWidgetBase {
2231
2629
  }
2232
2630
  _sync() {
2233
2631
  if (!this._element) return;
2234
- if (this.text !== this.lastText) {
2235
- this.lastText = this.text;
2236
- 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;
2237
2636
  }
2238
2637
  this._element.disabled = this.disabled;
2239
2638
  this._element.style.opacity = this.disabled ? "0.45" : "1";
@@ -2358,6 +2757,9 @@ var UiDialogue = class extends HudWidgetBase {
2358
2757
  super.update(dt);
2359
2758
  if (!this.current && this.queue.length > 0) {
2360
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));
2361
2763
  this.revealed = this.charsPerSecond <= 0 ? this.current.text.length : 0;
2362
2764
  this.emit("lineShown", this.current.text);
2363
2765
  if (this._element) {
@@ -2715,13 +3117,15 @@ var UiSelect = class extends HudWidgetBase {
2715
3117
  this.emit("changed", next);
2716
3118
  }
2717
3119
  select = null;
3120
+ labelEl = null;
3121
+ lastLabel = "x";
2718
3122
  lastOptions = "\0";
2719
3123
  last = "\0";
2720
3124
  _build() {
2721
3125
  const wrap = document.createElement("label");
2722
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;";
2723
3127
  const text = document.createElement("span");
2724
- text.textContent = this.label;
3128
+ this.labelEl = text;
2725
3129
  const select = document.createElement("select");
2726
3130
  select.addEventListener("change", () => {
2727
3131
  this.value = select.value;
@@ -2734,6 +3138,10 @@ var UiSelect = class extends HudWidgetBase {
2734
3138
  this.fillOptions();
2735
3139
  return wrap;
2736
3140
  }
3141
+ /** Override point: what an option READS as, when it differs from its value. */
3142
+ _optionLabel(value) {
3143
+ return value;
3144
+ }
2737
3145
  fillOptions() {
2738
3146
  const select = this.select;
2739
3147
  if (!select || this.options === this.lastOptions) return;
@@ -2744,13 +3152,18 @@ var UiSelect = class extends HudWidgetBase {
2744
3152
  if (!value) continue;
2745
3153
  const option = document.createElement("option");
2746
3154
  option.value = value;
2747
- option.textContent = value;
3155
+ option.textContent = this._optionLabel(value);
2748
3156
  select.appendChild(option);
2749
3157
  }
2750
3158
  if (this.value) select.value = this.value;
2751
3159
  this.last = this.value;
2752
3160
  }
2753
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
+ }
2754
3167
  this.fillOptions();
2755
3168
  if (this.select && this.value !== this.last) {
2756
3169
  this.last = this.value;
@@ -2758,6 +3171,70 @@ var UiSelect = class extends HudWidgetBase {
2758
3171
  }
2759
3172
  }
2760
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
+ };
2761
3238
  //#endregion
2762
3239
  //#region src/core/register.ts
2763
3240
  /**
@@ -2780,6 +3257,7 @@ function registerCoreNodes() {
2780
3257
  registerNode(UiSlider);
2781
3258
  registerNode(UiToggle);
2782
3259
  registerNode(UiSelect);
3260
+ registerNode(UiLanguageSelect);
2783
3261
  }
2784
3262
  //#endregion
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 };
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 };
@@ -1,5 +1,5 @@
1
- import { f as Node, t as buildNodeJson, x as Signal } from "./loader-BwR0DxeM.js";
2
- import { t as registerCoreNodes } from "./register-G3edsjJ2.js";
1
+ import { C as Signal, f as Node, t as buildNodeJson } from "./loader-Mig5fY4n.js";
2
+ import { t as registerCoreNodes } from "./register-BFLg0-_i.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-CJdGpT2V.js";
@@ -1,9 +1,9 @@
1
- import { f as Node } from "./loader-BwR0DxeM.js";
2
- import { t as registerCoreNodes } from "./register-G3edsjJ2.js";
1
+ import { f as Node } from "./loader-Mig5fY4n.js";
2
+ import { t as registerCoreNodes } from "./register-BFLg0-_i.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-CJdGpT2V.js";
6
- import { r as effectiveOrder } from "./duplicate-DlOvknDO.js";
6
+ import { r as effectiveOrder } from "./duplicate-C716f-97.js";
7
7
  import { i as applyParticlePreset, r as PARTICLE_PRESET_NAMES, t as ParticleSim } from "./particle-sim-Bw7hB93B.js";
8
8
  import { AdditiveBlending, BufferAttribute, BufferGeometry, CanvasTexture, Color, DynamicDrawUsage, Group, InstancedMesh, MathUtils, Matrix4, Mesh, MeshBasicMaterial, NearestFilter, NormalBlending, Object3D, PlaneGeometry } from "three";
9
9
  //#region src/2d/physics/collider-validate.ts
@@ -266,7 +266,10 @@ var Joint2D = class extends Node2D {
266
266
  "spring"
267
267
  ]
268
268
  },
269
- target: { default: "" },
269
+ target: {
270
+ default: "",
271
+ nodePath: true
272
+ },
270
273
  anchor: { default: [0, 0] },
271
274
  targetAnchor: { default: [0, 0] },
272
275
  length: { default: 0 },
@@ -482,7 +485,10 @@ function resolveFrames(frames, name) {
482
485
  var Camera2D = class extends Node2D {
483
486
  static typeName = "Camera2D";
484
487
  static props = {
485
- follow: { default: "" },
488
+ follow: {
489
+ default: "",
490
+ nodePath: true
491
+ },
486
492
  smoothing: { default: 0 },
487
493
  zoom: { default: 1 },
488
494
  limits: { default: [] },