incanto 0.58.0 → 0.60.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 (60) hide show
  1. package/bin/incanto-check.mjs +45 -4
  2. package/bin/incanto-multiplay.mjs +170 -0
  3. package/bin/incanto-verify.mjs +68 -4
  4. package/bin/incanto.mjs +1 -0
  5. package/dist/2d.d.ts +8 -2
  6. package/dist/2d.js +3 -3
  7. package/dist/3d.d.ts +9 -3
  8. package/dist/3d.js +4 -4
  9. package/dist/{behavior-l08AEbq9.d.ts → behavior-DWKTUzKI.d.ts} +10 -0
  10. package/dist/{create-game-D10bU5_J.js → create-game-BCm38FJV.js} +6 -6
  11. package/dist/{create-game-BZwWJIns.js → create-game-ClnIb_M5.js} +6 -6
  12. package/dist/debug.d.ts +1 -1
  13. package/dist/debug.js +2 -2
  14. package/dist/{duplicate-BPLZDZpd.js → duplicate-DJQd44CD.js} +1 -1
  15. package/dist/{environment-presets-CvvQr_bJ.js → environment-presets-8cjF3t6w.js} +12 -10
  16. package/dist/{gameplay-BVphcxmE.js → gameplay-BBEjPFsR.js} +62 -34
  17. package/dist/gameplay.d.ts +1 -1
  18. package/dist/gameplay.js +1 -1
  19. package/dist/index.d.ts +53 -8
  20. package/dist/index.js +8 -8
  21. package/dist/{json-BLk7H2Qa.js → json-CwwhxQgb.js} +7 -1
  22. package/dist/{loader-BcrRSjxB.js → loader-D8n7TU8W.js} +142 -5
  23. package/dist/{loader-BbEMTuWg.d.ts → loader-TvkRFbyL.d.ts} +1 -1
  24. package/dist/net.d.ts +2 -2
  25. package/dist/net.js +1 -1
  26. package/dist/{pathfinding-mEN4V1CU.d.ts → pathfinding-BqWBb0kh.d.ts} +1 -1
  27. package/dist/{physics-2d-EqA-jddf.js → physics-2d-DqdVp1bt.js} +12 -2
  28. package/dist/{physics-3d-Dnz4fsXX.js → physics-3d-BP0DZb_1.js} +13 -3
  29. package/dist/react.d.ts +1 -1
  30. package/dist/react.js +2 -2
  31. package/dist/{register-C6ZBFRjd.js → register-BSu2dWGC.js} +57 -27
  32. package/dist/{register-Ch70uByv.js → register-Da3hXh2H.js} +137 -46
  33. package/dist/{registry-C7u42TID.js → registry-WWcQcfMr.js} +1 -1
  34. package/dist/{replay-Dw6gMlYA.d.ts → replay-BCMK_VRP.d.ts} +15 -2
  35. package/dist/{replay-s7I2GstT.js → replay-BlNuIDdg.js} +41 -13
  36. package/dist/{split-screen-B0baBwxI.d.ts → split-screen-BQ3tAsf-.d.ts} +38 -1
  37. package/dist/{split-screen-DLsUrleX.js → split-screen-CL5Yvxse.js} +56 -13
  38. package/dist/{sprite-animation-C0wXLBZJ.js → sprite-animation-CY-mrr1L.js} +1 -1
  39. package/dist/{src-C1J09Op6.js → src-DFpXBMJN.js} +1 -1
  40. package/dist/{teardown-Cs113S9F.js → teardown-CTTwhWSe.js} +1 -1
  41. package/dist/{test-ZBga8kQ9.js → test-BeZ95pqw.js} +264 -25
  42. package/dist/test.d.ts +119 -5
  43. package/dist/test.js +3 -3
  44. package/dist/vite.js +2 -2
  45. package/editor/assets/{agent8-BQQjE9UQ.js → agent8-DSJries_.js} +1 -1
  46. package/editor/assets/{debug-C9UCsXBS.js → debug-D15Wi5TO.js} +1 -1
  47. package/editor/assets/{index-CBgfM3WD.js → index-BjC88k97.js} +91 -91
  48. package/editor/index.html +1 -1
  49. package/package.json +2 -1
  50. package/schemas/scene.schema.json +1174 -70
  51. package/skills/incanto-localization.md +64 -8
  52. package/skills/incanto-multiplayer.md +57 -4
  53. package/skills/incanto-node-reference.md +18 -0
  54. package/skills/incanto-physics-and-input.md +26 -0
  55. package/skills/incanto-verifying-your-game.md +22 -0
  56. package/templates-app/beacon-isle-3d/package.json +1 -1
  57. package/templates-app/platformer-2d/package.json +1 -1
  58. package/templates-app/star-survivor/package.json +1 -1
  59. package/templates-app/tps-3d/package.json +1 -1
  60. package/templates-app/village-quest-3d/package.json +1 -1
@@ -1,9 +1,9 @@
1
- import { C as diagnose, S as Node, y as effectiveOrder } from "./loader-BcrRSjxB.js";
2
- import { t as registerCoreNodes } from "./register-Ch70uByv.js";
1
+ import { C as Node, b as effectiveOrder, w as diagnose } from "./loader-D8n7TU8W.js";
2
+ import { I as translateOn, t as registerCoreNodes } from "./register-Da3hXh2H.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
5
- import { i as getNodeSchema, l as registerNode } from "./registry-C7u42TID.js";
6
- import { i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, r as validateAnimationAliases, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-C0wXLBZJ.js";
5
+ import { i as getNodeSchema, l as registerNode } from "./registry-WWcQcfMr.js";
6
+ import { i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, r as validateAnimationAliases, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-CY-mrr1L.js";
7
7
  import { t as checkSheetGrid } from "./sheet-grid-BT6N_Bjs.js";
8
8
  import { AdditiveBlending, BufferAttribute, BufferGeometry, CanvasTexture, Color, DynamicDrawUsage, Group, InstancedMesh, MathUtils, Matrix4, Mesh, MeshBasicMaterial, NearestFilter, NormalBlending, Object3D, PlaneGeometry, SRGBColorSpace } from "three";
9
9
  //#region src/2d/physics/collider-validate.ts
@@ -115,32 +115,51 @@ var Node2D = class extends Node {
115
115
  * `{shape:'capsule', radius, height}` — never child shape nodes.
116
116
  */
117
117
  var PhysicsBody2D = class extends Node2D {
118
- static props = { collider: {
119
- default: {},
120
- variants: {
121
- tag: "shape",
122
- byTag: {
123
- rect: {
124
- shape: "rect",
125
- size: [32, 32]
126
- },
127
- circle: {
128
- shape: "circle",
129
- radius: 16
130
- },
131
- capsule: {
132
- shape: "capsule",
133
- radius: 12,
134
- height: 32
118
+ static props = {
119
+ collider: {
120
+ default: {},
121
+ variants: {
122
+ tag: "shape",
123
+ byTag: {
124
+ rect: {
125
+ shape: "rect",
126
+ size: [32, 32]
127
+ },
128
+ circle: {
129
+ shape: "circle",
130
+ radius: 16
131
+ },
132
+ capsule: {
133
+ shape: "capsule",
134
+ radius: 12,
135
+ height: 32
136
+ }
135
137
  }
136
138
  }
137
- }
138
- } };
139
+ },
140
+ /**
141
+ * Whether this body's collider takes part in physics right now.
142
+ *
143
+ * A melee hitbox is off most of the time and live for a few frames of a
144
+ * swing, and there was no way to say so: games expressed it by REPLACING
145
+ * `collider` with `{}`, which tears the rigid body down and builds a new
146
+ * one twice per swing, and makes the engine warn `has no collider —
147
+ * physics skips it` about a scene that is correct. Keep the real collider
148
+ * and turn it off.
149
+ *
150
+ * Off means no contacts and no `triggerEnter`/`triggerExit`. The body
151
+ * itself stays, so nothing is rebuilt and re-arming is one call.
152
+ */
153
+ enabled: { default: true }
154
+ };
139
155
  /** The unified collision model: every collider participant can emit these. */
140
156
  static signals = ["triggerEnter", "triggerExit"];
141
157
  _collider = {};
142
158
  /** @internal Physics resync flag — raised when `collider` is REPLACED. */
143
159
  _colliderChanged = false;
160
+ _enabled = true;
161
+ /** @internal Physics resync flag — raised when `enabled` is written. */
162
+ _enabledChanged = false;
144
163
  /**
145
164
  * Collider shape prop. Change it by REPLACING the object
146
165
  * (`body.collider = { shape: 'box', size: [...] }`) — physics rebuilds the
@@ -153,6 +172,15 @@ var PhysicsBody2D = class extends Node2D {
153
172
  this._collider = value;
154
173
  this._colliderChanged = true;
155
174
  }
175
+ /** See the `enabled` prop. Writing it arms or disarms the existing collider. */
176
+ get enabled() {
177
+ return this._enabled;
178
+ }
179
+ set enabled(value) {
180
+ if (value === this._enabled) return;
181
+ this._enabled = value;
182
+ this._enabledChanged = true;
183
+ }
156
184
  /** Loader hook: bad collider shapes fail at LOAD, not at physics start. */
157
185
  static validateJson(node) {
158
186
  validateCollider2D(node.collider, node.name);
@@ -825,11 +853,12 @@ var Label = class extends Node2D {
825
853
  _syncObject2D(assets) {
826
854
  super._syncObject2D(assets);
827
855
  const quad = this._quad();
828
- if (typeof document === "undefined" || this.text === "") {
856
+ const text = translateOn(this, this.text);
857
+ if (typeof document === "undefined" || text === "") {
829
858
  quad.visible = false;
830
859
  return;
831
860
  }
832
- const key = `${this.text}\0${this.fontSize}\0${this.color}\0${this.font}\0${this.align}`;
861
+ const key = `${text}\0${this.fontSize}\0${this.color}\0${this.font}\0${this.align}`;
833
862
  if (key !== this.lastKey) {
834
863
  this.lastKey = key;
835
864
  this.rasterize();
@@ -838,13 +867,14 @@ var Label = class extends Node2D {
838
867
  quad.renderOrder = this.effectiveRenderOrder;
839
868
  }
840
869
  rasterize() {
870
+ const text = translateOn(this, this.text);
841
871
  const dpr = Math.min(globalThis.devicePixelRatio ?? 1, 2);
842
872
  this.canvas ??= document.createElement("canvas");
843
873
  const ctx = this.canvas.getContext("2d");
844
874
  if (!ctx) return;
845
875
  const fontSpec = `${this.fontSize * dpr}px ${this.font}`;
846
876
  ctx.font = fontSpec;
847
- const metrics = ctx.measureText(this.text);
877
+ const metrics = ctx.measureText(text);
848
878
  const w = Math.max(1, Math.ceil(metrics.width));
849
879
  const h = Math.ceil(this.fontSize * dpr * 1.4);
850
880
  this.canvas.width = w;
@@ -852,7 +882,7 @@ var Label = class extends Node2D {
852
882
  ctx.font = fontSpec;
853
883
  ctx.fillStyle = this.color;
854
884
  ctx.textBaseline = "middle";
855
- ctx.fillText(this.text, 0, h / 2);
885
+ ctx.fillText(text, 0, h / 2);
856
886
  const quad = this._quad();
857
887
  const mat = quad.material;
858
888
  mat.map?.dispose();
@@ -1,7 +1,8 @@
1
- import { C as diagnose, M as Signal, S as Node, _ as createSaveStore, b as resolveOrderGroups, h as restoreBehaviors, m as captureBehaviors, v as ORDER_GROUP_BASE, x as InputMap } from "./loader-BcrRSjxB.js";
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
+ import { C as Node, N as Signal, S as InputMap, g as restoreBehaviors, h as captureBehaviors, v as createSaveStore, w as diagnose, x as resolveOrderGroups, y as ORDER_GROUP_BASE } from "./loader-D8n7TU8W.js";
2
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
4
  import { t as Rng } from "./rng-DP-SR7eg.js";
4
- import { l as registerNode } from "./registry-C7u42TID.js";
5
+ import { l as registerNode } from "./registry-WWcQcfMr.js";
5
6
  //#region src/core/audio/buses.ts
6
7
  const AUDIO_LOG_LIMIT = 200;
7
8
  var AudioBuses = class {
@@ -1160,6 +1161,18 @@ var Localization = class {
1160
1161
  const key = translationKey(value);
1161
1162
  return key === null ? value : this.t(key, params);
1162
1163
  }
1164
+ /**
1165
+ * Whether the ACTIVE locale or the base declares this key.
1166
+ *
1167
+ * This is the runtime question, and it is not the same as `has`: a key only
1168
+ * `ko` declares is "declared", and an English player still gets the raw key
1169
+ * back from `t()`, because the fallback chain is current → base → the key.
1170
+ * Widgets that ask before translating asked the wrong one and painted
1171
+ * `settings.quality.low` into an English menu.
1172
+ */
1173
+ declares(key) {
1174
+ return this.tables[this.current]?.[key] !== void 0 || this.tables["en"]?.[key] !== void 0;
1175
+ }
1163
1176
  /** Whether any locale declares this key — for the audit, not for the runtime. */
1164
1177
  has(key) {
1165
1178
  for (const entries of Object.values(this.tables)) if (entries[key] !== void 0) return true;
@@ -1182,6 +1195,22 @@ function suggestLocale(available, languages) {
1182
1195
  }
1183
1196
  return "en";
1184
1197
  }
1198
+ /**
1199
+ * Resolve a text prop on ANY node, not just a DOM HUD widget.
1200
+ *
1201
+ * `@t:` was honoured in exactly one place — `HudWidgetBase._t` — so text that
1202
+ * lives in the WORLD had no localization path at all: a `Label3D` reading
1203
+ * `"@t:sign.welcome"` rasterized those literal characters into the texture
1204
+ * standing over the shop door, in every language, and the audit certified the
1205
+ * scene clean because the key WAS declared.
1206
+ *
1207
+ * Returns the value untouched when there is no tree yet or no marker, so it is
1208
+ * safe to call from a rasterize path that runs before the node is in a scene.
1209
+ */
1210
+ function translateOn(node, value) {
1211
+ const locale = node.tree?.engine?.locale;
1212
+ return locale ? locale.resolve(value) : value;
1213
+ }
1185
1214
  //#endregion
1186
1215
  //#region src/core/nodes/hud.ts
1187
1216
  const ANCHOR_CSS = {
@@ -1536,6 +1565,45 @@ var HudWidgetBase = class extends Node {
1536
1565
  const locale = this.tree?.engine?.locale;
1537
1566
  return locale ? locale.resolve(value) : value;
1538
1567
  }
1568
+ /**
1569
+ * A translation with an English wording to fall back to.
1570
+ *
1571
+ * For text the ENGINE supplies — "Music", "Mute", "Unlimited" — where there
1572
+ * is no author string to carry a `@t:` marker. Asks `declares()`, not
1573
+ * `has()`: a key only another locale declares would otherwise come back as
1574
+ * the raw key, which is worse than the English.
1575
+ */
1576
+ /**
1577
+ * What this widget's label READS as — resolved every frame, never stored.
1578
+ *
1579
+ * `UiVolumeSlider` and `UiMuteToggle` used to compute their auto-label in
1580
+ * `_build()` and write the result back into `this.label`, which broke them
1581
+ * twice over: the per-frame `_t(this.label)` then had a plain string with no
1582
+ * marker left to resolve, and `_build` runs from `onReady` — inside
1583
+ * `loadScene`, BEFORE `setScene` merges the scene's `strings` — so the table
1584
+ * was empty at bake time and the English was permanent even for a game that
1585
+ * boots in another language.
1586
+ */
1587
+ /**
1588
+ * What this widget actually PAINTS, when that differs from its props.
1589
+ *
1590
+ * A headless capture printed `text="@t:menu.start"` — byte-identical in
1591
+ * every language — so a passing capture proved nothing about localization
1592
+ * and a broken translation was invisible to every check the engine has.
1593
+ * `incanto-frame` reads the WebGL buffer and cannot read DOM text at all.
1594
+ *
1595
+ * `undefined` when the painted words ARE the prop: recording it twice would
1596
+ * be noise on every widget in a game that never localizes anything.
1597
+ */
1598
+ _paintedText() {}
1599
+ _labelText() {
1600
+ const label = this.label;
1601
+ return typeof label === "string" ? this._t(label) : "";
1602
+ }
1603
+ _tOr(key, fallback) {
1604
+ const locale = this.tree?.engine?.locale;
1605
+ return locale?.declares(key) ? locale.t(key) : fallback;
1606
+ }
1539
1607
  };
1540
1608
  /** A text line (score, timer, hints). Set `.text` from behaviors. */
1541
1609
  var UiText = class extends HudWidgetBase {
@@ -1586,6 +1654,10 @@ var UiText = class extends HudWidgetBase {
1586
1654
  if (this.format && this.slot !== null) return this._t(this.format).replace("{}", this.slot);
1587
1655
  return this._t(this.text);
1588
1656
  }
1657
+ _paintedText() {
1658
+ const painted = this.shown;
1659
+ return painted === this.text ? void 0 : painted;
1660
+ }
1589
1661
  _build() {
1590
1662
  const el = document.createElement("div");
1591
1663
  el.style.cssText = this.look();
@@ -1756,6 +1828,7 @@ var UiBanner = class extends HudWidgetBase {
1756
1828
  show(text, opts) {
1757
1829
  this.queue.push({
1758
1830
  text,
1831
+ source: text,
1759
1832
  color: opts?.color ?? "#ffffff",
1760
1833
  seconds: opts?.seconds ?? this.seconds
1761
1834
  });
@@ -1788,6 +1861,11 @@ var UiBanner = class extends HudWidgetBase {
1788
1861
  this._element.style.opacity = opacity;
1789
1862
  }
1790
1863
  if (this.current) {
1864
+ const next = this._t(this.current.source);
1865
+ if (next !== this.current.text) {
1866
+ this.current.text = next;
1867
+ if (this._element) this._element.textContent = next;
1868
+ }
1791
1869
  if (this.current.seconds > 0) {
1792
1870
  this.remaining -= dt;
1793
1871
  if (this.remaining <= 0) {
@@ -1798,7 +1876,7 @@ var UiBanner = class extends HudWidgetBase {
1798
1876
  }
1799
1877
  if (!this.current && this.queue.length > 0) {
1800
1878
  this.current = this.queue.shift();
1801
- this.current.text = this._t(this.current.text);
1879
+ this.current.text = this._t(this.current.source);
1802
1880
  this.remaining = this.current.seconds;
1803
1881
  if (this._element) {
1804
1882
  this._element.textContent = this.current.text;
@@ -2126,7 +2204,7 @@ var Settings = class {
2126
2204
  * without knowing this class exists. Returns the unsubscribe.
2127
2205
  */
2128
2206
  bindLocale(locale) {
2129
- locale.locale = this.values.language;
2207
+ locale.locale = this.store.get("language", "") || suggestLocale(locale.available());
2130
2208
  return locale.changed.connect((next) => {
2131
2209
  this.set("language", next);
2132
2210
  });
@@ -2229,6 +2307,7 @@ function qualityRendering(tier) {
2229
2307
  }
2230
2308
  //#endregion
2231
2309
  //#region src/core/engine.ts
2310
+ var engine_exports = /* @__PURE__ */ __exportAll({ Engine: () => Engine });
2232
2311
  const MAX_DT_SECONDS = .25;
2233
2312
  function rafScheduler(cb) {
2234
2313
  let live = true;
@@ -3050,10 +3129,14 @@ var UiDialogue = class extends HudWidgetBase {
3050
3129
  }
3051
3130
  /** Queue a line; optional `choices` renders buttons after the line types out. */
3052
3131
  say(speaker, text, choices) {
3053
- this.queue.push({
3132
+ const source = {
3054
3133
  speaker,
3055
3134
  text,
3056
3135
  choices: choices ?? null
3136
+ };
3137
+ this.queue.push({
3138
+ ...source,
3139
+ source
3057
3140
  });
3058
3141
  }
3059
3142
  /**
@@ -3107,13 +3190,22 @@ var UiDialogue = class extends HudWidgetBase {
3107
3190
  box.addEventListener("click", () => this.advance());
3108
3191
  return box;
3109
3192
  }
3193
+ /** The locale `current` was resolved in, so a switch is one compare away. */
3194
+ resolvedIn = "\0";
3195
+ /** Re-read the live line from its untouched source. */
3196
+ resolveCurrent() {
3197
+ const line = this.current;
3198
+ if (!line) return;
3199
+ line.text = this._t(line.source.text);
3200
+ line.speaker = this._t(line.source.speaker);
3201
+ line.choices = line.source.choices ? line.source.choices.map((c) => this._t(c)) : null;
3202
+ this.resolvedIn = this.tree?.engine?.locale.locale ?? "";
3203
+ }
3110
3204
  update(dt) {
3111
3205
  super.update(dt);
3112
3206
  if (!this.current && this.queue.length > 0) {
3113
3207
  this.current = this.queue.shift();
3114
- this.current.text = this._t(this.current.text);
3115
- this.current.speaker = this._t(this.current.speaker);
3116
- if (this.current.choices) this.current.choices = this.current.choices.map((c) => this._t(c));
3208
+ this.resolveCurrent();
3117
3209
  this.revealed = this.charsPerSecond <= 0 ? this.current.text.length : 0;
3118
3210
  this.emit("lineShown", this.current.text);
3119
3211
  if (this._element) {
@@ -3123,6 +3215,13 @@ var UiDialogue = class extends HudWidgetBase {
3123
3215
  }
3124
3216
  if (this._element) this._element.style.display = this.visible && this.current ? "block" : "none";
3125
3217
  if (!this.current) return;
3218
+ if ((this.tree?.engine?.locale.locale ?? "") !== this.resolvedIn) {
3219
+ const before = this.current.text.length;
3220
+ const progress = before > 0 ? this.revealed / before : 1;
3221
+ this.resolveCurrent();
3222
+ this.revealed = Math.round(progress * this.current.text.length);
3223
+ if (this.speakerEl) this.speakerEl.textContent = this.current.speaker;
3224
+ }
3126
3225
  const wasTyping = this.revealed < this.current.text.length;
3127
3226
  if (wasTyping) this.revealed = Math.min(this.current.text.length, this.revealed + this.charsPerSecond * dt);
3128
3227
  const doneTyping = this.revealed >= this.current.text.length;
@@ -3193,17 +3292,6 @@ function px(n) {
3193
3292
  return `${n}px`;
3194
3293
  }
3195
3294
  /**
3196
- * The translated string, or the English one.
3197
- *
3198
- * `locale.t` returns the KEY when nothing declares it, and a menu reading
3199
- * `settings.volume.music` is worse than one reading `Music`. English is the
3200
- * base language and the fallback, always.
3201
- */
3202
- function translated(engine, key, fallback) {
3203
- const locale = engine?.locale;
3204
- return locale?.has(key) ? locale.t(key) : fallback;
3205
- }
3206
- /**
3207
3295
  * A box that holds other widgets — the thing a menu actually is.
3208
3296
  *
3209
3297
  * Widgets normally mount into their HudLayer's anchor slot, which is why a
@@ -3377,7 +3465,7 @@ var UiSlider = class extends HudWidgetBase {
3377
3465
  const wrap = document.createElement("div");
3378
3466
  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;";
3379
3467
  const text = document.createElement("span");
3380
- text.textContent = this._t(this.label);
3468
+ text.textContent = this._labelText();
3381
3469
  const input = document.createElement("input");
3382
3470
  input.type = "range";
3383
3471
  input.min = String(this.min);
@@ -3390,7 +3478,7 @@ var UiSlider = class extends HudWidgetBase {
3390
3478
  this.last = this.value;
3391
3479
  this.emit("changed", this.value);
3392
3480
  });
3393
- if (this.label) wrap.appendChild(text);
3481
+ if (this._labelText()) wrap.appendChild(text);
3394
3482
  wrap.appendChild(input);
3395
3483
  this.input = input;
3396
3484
  this.text = text;
@@ -3402,7 +3490,7 @@ var UiSlider = class extends HudWidgetBase {
3402
3490
  this.last = this.value;
3403
3491
  this.input.value = String(this.value);
3404
3492
  }
3405
- const label = this._t(this.label);
3493
+ const label = this._labelText();
3406
3494
  if (this.text && this.text.textContent !== label) this.text.textContent = label;
3407
3495
  }
3408
3496
  };
@@ -3442,9 +3530,9 @@ var UiToggle = class extends HudWidgetBase {
3442
3530
  this.emit("changed", this.value);
3443
3531
  });
3444
3532
  const text = document.createElement("span");
3445
- text.textContent = this._t(this.label);
3533
+ text.textContent = this._labelText();
3446
3534
  this.tag = text;
3447
- this.lastLabel = this._t(this.label);
3535
+ this.lastLabel = this._labelText();
3448
3536
  wrap.append(input, text);
3449
3537
  this.input = input;
3450
3538
  this.last = this.value;
@@ -3457,7 +3545,7 @@ var UiToggle = class extends HudWidgetBase {
3457
3545
  this.last = this.value;
3458
3546
  this.input.checked = this.value;
3459
3547
  }
3460
- const label = this._t(this.label);
3548
+ const label = this._labelText();
3461
3549
  if (this.tag && label !== this.lastLabel) {
3462
3550
  this.lastLabel = label;
3463
3551
  this.tag.textContent = label;
@@ -3507,24 +3595,33 @@ var UiSelect = class extends HudWidgetBase {
3507
3595
  this.last = this.value;
3508
3596
  this.emit("changed", this.value);
3509
3597
  });
3510
- if (this.label) wrap.appendChild(text);
3598
+ if (this._labelText()) wrap.appendChild(text);
3511
3599
  wrap.appendChild(select);
3512
3600
  this.select = select;
3513
3601
  this.fillOptions();
3514
3602
  return wrap;
3515
3603
  }
3516
- /** Override point: what an option READS as, when it differs from its value. */
3604
+ /**
3605
+ * What an option READS as, when it differs from its value.
3606
+ *
3607
+ * The base resolves `@t:` like every other text prop: the VALUE stays exactly
3608
+ * what the author wrote — so a `changed` handler never has to know the active
3609
+ * language — and only the displayed words change. Without this a dropdown was
3610
+ * the one widget with no way to speak anything but English, and
3611
+ * `"options": "@t:diff.easy"` rendered that marker literally on screen.
3612
+ */
3517
3613
  _optionLabel(value) {
3518
- return value;
3614
+ return this._t(value);
3519
3615
  }
3520
3616
  fillOptions() {
3521
3617
  const select = this.select;
3522
- if (!select || this.options === this.lastOptions) return;
3523
- this.lastOptions = this.options;
3618
+ if (!select) return;
3619
+ const values = this.options.split(",").map((o) => o.trim()).filter(Boolean);
3620
+ const painted = values.map((v) => `${v}\u0000${this._optionLabel(v)}`).join("");
3621
+ if (painted === this.lastOptions) return;
3622
+ this.lastOptions = painted;
3524
3623
  select.textContent = "";
3525
- for (const raw of this.options.split(",")) {
3526
- const value = raw.trim();
3527
- if (!value) continue;
3624
+ for (const value of values) {
3528
3625
  const option = document.createElement("option");
3529
3626
  option.value = value;
3530
3627
  option.textContent = this._optionLabel(value);
@@ -3635,9 +3732,6 @@ var UiSettingSelect = class extends UiSelect {
3635
3732
  * `settings.quality.high` is worse than one reading `High`. English is the
3636
3733
  * base language and the fallback, always.
3637
3734
  */
3638
- _tOr(key, fallback) {
3639
- return translated(this._engine(), key, fallback);
3640
- }
3641
3735
  _sync() {
3642
3736
  const engine = this._engine();
3643
3737
  if (engine) {
@@ -3807,12 +3901,10 @@ var UiVolumeSlider = class extends UiSlider {
3807
3901
  bus = "master";
3808
3902
  /** Last value we PUSHED in, to tell an engine-side change from a player drag. */
3809
3903
  lastPushed = null;
3810
- _build() {
3811
- if (!this.label) {
3812
- const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
3813
- this.label = translated(this.tree?.engine ?? null, `settings.volume.${this.bus}`, english);
3814
- }
3815
- return super._build();
3904
+ _labelText() {
3905
+ if (this.label) return this._t(this.label);
3906
+ const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
3907
+ return this._tOr(`settings.volume.${this.bus}`, english);
3816
3908
  }
3817
3909
  _sync() {
3818
3910
  const audio = this.tree?.engine?.audio;
@@ -3846,9 +3938,8 @@ var UiMuteToggle = class extends UiToggle {
3846
3938
  label: { default: "" }
3847
3939
  };
3848
3940
  lastPushed = null;
3849
- _build() {
3850
- if (!this.label) this.label = translated(this.tree?.engine ?? null, "settings.mute", "Mute");
3851
- return super._build();
3941
+ _labelText() {
3942
+ return this.label ? this._t(this.label) : this._tOr("settings.mute", "Mute");
3852
3943
  }
3853
3944
  _sync() {
3854
3945
  const audio = this.tree?.engine?.audio;
@@ -3894,4 +3985,4 @@ function registerCoreNodes() {
3894
3985
  registerNode(UiMuteToggle);
3895
3986
  }
3896
3987
  //#endregion
3897
- export { UiText as A, spatialPan as B, suggestQuality as C, HudWidgetBase as D, HudLayer as E, translationKey as F, WebAudioMusicBackend as G, SFX_PRESET_NAMES as H, SfxEngine as I, AudioBuses as J, crossfadeGains as K, isAudioContextAvailable as L, Localization as M, T_PREFIX as N, UiBanner as O, suggestLocale as P, ROLLOFF_MODELS as R, readDeviceHints as S, EffectLog as T, synthSfx as U, SFX_PRESETS as V, MusicManager as W, Engine as _, UiMuteToggle as a, qualityEnvironment as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, AudioPlayer as g, UiDialogue as h, UiLanguageSelect as i, BASE_LOCALE as j, UiBar as k, UiSelect as l, UiButton as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, fadeGain as q, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, Settings as v, LogManager as w, qualityRendering as x, qualityCaps as y, spatialGain as z };
3988
+ export { UiBar as A, ROLLOFF_MODELS as B, readDeviceHints as C, HudLayer as D, EffectLog as E, suggestLocale as F, synthSfx as G, spatialPan as H, translateOn as I, crossfadeGains as J, MusicManager as K, translationKey as L, BASE_LOCALE as M, Localization as N, HudWidgetBase as O, T_PREFIX as P, SfxEngine as R, qualityRendering as S, LogManager as T, SFX_PRESETS as U, spatialGain as V, SFX_PRESET_NAMES as W, AudioBuses as X, fadeGain as Y, Engine as _, UiMuteToggle as a, qualityCaps as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, AudioPlayer as g, UiDialogue as h, UiLanguageSelect as i, UiText as j, UiBanner as k, UiSelect as l, UiButton as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, WebAudioMusicBackend as q, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, engine_exports as v, suggestQuality as w, qualityEnvironment as x, Settings as y, isAudioContextAvailable as z };
@@ -1,5 +1,5 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { r as jsonKind, t as jsonClone } from "./json-BLk7H2Qa.js";
2
+ import { r as jsonKind, t as jsonClone } from "./json-CwwhxQgb.js";
3
3
  //#region src/core/registry.ts
4
4
  /**
5
5
  * Validate a prop value against its schema default:
@@ -1,9 +1,22 @@
1
- import { b as Engine } from "./behavior-l08AEbq9.js";
1
+ import { b as Engine } from "./behavior-DWKTUzKI.js";
2
2
  import { s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/core/audit.d.ts
5
5
  /** Human-readable warnings (empty = clean). Pure JSON walk, no registry. */
6
- declare function auditScene(scene: JsonObject): string[];
6
+ interface AuditOptions {
7
+ /**
8
+ * Every locale table the whole PROJECT declares, unioned.
9
+ *
10
+ * Localization tables MERGE across scenes — a game's shared UI strings live
11
+ * in the scene it boots from, and a level that adds three lines of its own
12
+ * does not wipe them. Auditing one file in isolation therefore reported a key
13
+ * declared in the boot scene as "nothing declares" it, and told its author to
14
+ * add a string they already have. The checker passes the corpus in when it
15
+ * has one.
16
+ */
17
+ declaredElsewhere?: Record<string, Record<string, string>>;
18
+ }
19
+ declare function auditScene(scene: JsonObject, opts?: AuditOptions): string[];
7
20
  //#endregion
8
21
  //#region src/core/errors.d.ts
9
22
  /**
@@ -1,6 +1,6 @@
1
- import { E as behaviorSchema, w as parseNodePath } from "./loader-BcrRSjxB.js";
2
- import { D as HudWidgetBase, F as translationKey } from "./register-Ch70uByv.js";
3
- import { i as getNodeSchema, o as getNodeType } from "./registry-C7u42TID.js";
1
+ import { D as behaviorSchema, T as parseNodePath } from "./loader-D8n7TU8W.js";
2
+ import { L as translationKey, O as HudWidgetBase } from "./register-Da3hXh2H.js";
3
+ import { i as getNodeSchema, o as getNodeType } from "./registry-WWcQcfMr.js";
4
4
  //#region src/core/node-refs.ts
5
5
  /**
6
6
  * Node-path PROPS, resolved against scene JSON.
@@ -266,8 +266,7 @@ function isLit(environment) {
266
266
  const ambient = environment.ambient;
267
267
  return typeof ambient?.intensity === "number" && ambient.intensity > 0;
268
268
  }
269
- /** Human-readable warnings (empty = clean). Pure JSON walk, no registry. */
270
- function auditScene(scene) {
269
+ function auditScene(scene, opts = {}) {
271
270
  const warnings = [];
272
271
  const root = scene.root;
273
272
  if (!root) return warnings;
@@ -301,7 +300,7 @@ function auditScene(scene) {
301
300
  if (currentCameras > 1) warnings.push(`${currentCameras} cameras claim "current": true — only the first found wins.`);
302
301
  if (scene.dimension === "3d" && lights === 0 && !isLit(scene.environment)) warnings.push("nothing lights this 3D scene — no light node, no environment sky/hdri, and no ambient intensity. It will render black. Add a DirectionalLight3D, or an \"environment\": { \"sky\": { \"type\": \"atmosphere\" } } / { \"preset\": \"...\" }.");
303
302
  for (const warning of nodeRefWarnings(scene)) warnings.push(warning);
304
- for (const warning of auditStrings(scene)) warnings.push(warning);
303
+ for (const warning of auditStrings(scene, opts.declaredElsewhere)) warnings.push(warning);
305
304
  return warnings;
306
305
  }
307
306
  /**
@@ -317,15 +316,33 @@ function auditScene(scene) {
317
316
  * screen. Reported here — at author time, where it can still be fixed — rather
318
317
  * than at runtime in front of someone playing.
319
318
  */
320
- function auditStrings(scene) {
321
- const tables = scene.strings;
319
+ /**
320
+ * Close to `@t:` without being it — `@T:`, a leading space, `@t :`, `@t.`.
321
+ * Deliberately narrow: an at-sign in ordinary prose ("email me @ home") must
322
+ * not trip it.
323
+ */
324
+ const NEAR_MISS = /^\s*@\s*t\s*[:.]/i;
325
+ function auditStrings(scene, elsewhere) {
326
+ const own = scene.strings;
327
+ const tables = {};
328
+ for (const source of [elsewhere, own]) for (const [locale, entries] of Object.entries(source ?? {})) tables[locale] = {
329
+ ...tables[locale] ?? {},
330
+ ...entries
331
+ };
322
332
  const declared = /* @__PURE__ */ new Set();
323
- for (const entries of Object.values(tables ?? {})) for (const key of Object.keys(entries)) declared.add(key);
333
+ for (const entries of Object.values(tables)) for (const key of Object.keys(entries)) declared.add(key);
334
+ const inBase = new Set(Object.keys(tables["en"] ?? {}));
324
335
  const missing = /* @__PURE__ */ new Set();
336
+ const notInEnglish = /* @__PURE__ */ new Set();
337
+ const nearMiss = /* @__PURE__ */ new Set();
325
338
  const walk = (value) => {
326
339
  if (typeof value === "string") {
340
+ if (translationKey(value) === null && NEAR_MISS.test(value)) nearMiss.add(value);
327
341
  const key = translationKey(value);
328
- if (key !== null && !declared.has(key)) missing.add(key);
342
+ if (key !== null) {
343
+ if (!declared.has(key)) missing.add(key);
344
+ else if (declared.size > 0 && !inBase.has(key)) notInEnglish.add(key);
345
+ }
329
346
  return;
330
347
  }
331
348
  if (Array.isArray(value)) {
@@ -335,9 +352,20 @@ function auditStrings(scene) {
335
352
  if (value !== null && typeof value === "object") for (const item of Object.values(value)) walk(item);
336
353
  };
337
354
  walk(scene.root);
338
- if (missing.size === 0) return [];
339
- const listed = [...missing].sort();
340
- return [`${listed.length} translation key${listed.length === 1 ? "" : "s"} nothing declares: ${listed.join(", ")}. The key itself renders on screen. Add them under "strings": { "en": { ... } } — other locales may omit any of them and fall back to English.`];
355
+ const out = [];
356
+ if (missing.size > 0) {
357
+ const listed = [...missing].sort();
358
+ out.push(`${listed.length} translation key${listed.length === 1 ? "" : "s"} nothing declares: ${listed.join(", ")}. The key itself renders on screen. Add them under "strings": { "en": { ... } } — other locales may omit any of them and fall back to English.`);
359
+ }
360
+ if (nearMiss.size > 0) {
361
+ const listed = [...nearMiss].sort();
362
+ out.push(`${listed.length} value${listed.length === 1 ? "" : "s"} look${listed.length === 1 ? "s" : ""} like a translation marker but ${listed.length === 1 ? "is" : "are"} not: ${listed.map((v) => JSON.stringify(v)).join(", ")}. It must be exactly "@t:key" — lower-case, no space. As written, the text itself renders on screen.`);
363
+ }
364
+ if (notInEnglish.size > 0) {
365
+ const listed = [...notInEnglish].sort();
366
+ out.push(`${listed.length} translation key${listed.length === 1 ? "" : "s"} missing from "en": ${listed.join(", ")}. English is the base every other locale falls back to, so these render as the raw key for anyone not using the locale that declares them.`);
367
+ }
368
+ return out;
341
369
  }
342
370
  //#endregion
343
371
  //#region src/core/replay.ts