incanto 0.60.0 → 0.62.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 (68) hide show
  1. package/bin/incanto-check.mjs +13 -0
  2. package/bin/incanto-verify.mjs +134 -55
  3. package/dist/2d.d.ts +64 -3
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +5 -5
  6. package/dist/3d.js +5 -4
  7. package/dist/{pathfinding-BqWBb0kh.d.ts → audio-player-D5GJgb_x.d.ts} +103 -38
  8. package/dist/{behavior-DWKTUzKI.d.ts → behavior-DsgayMsH.d.ts} +79 -1
  9. package/dist/{create-game-ClnIb_M5.js → create-game-BpunnGPX.js} +78 -13
  10. package/dist/{create-game-BCm38FJV.js → create-game-Caut3bqN.js} +21 -294
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/debug.js +1 -1
  13. package/dist/{duplicate-DJQd44CD.js → duplicate-E4FUs5Bn.js} +1 -1
  14. package/dist/editor.js +41 -19
  15. package/dist/{environment-presets-8cjF3t6w.js → environment-presets-BAWeOeqf.js} +50 -13
  16. package/dist/{gameplay-BBEjPFsR.js → gameplay-CaHqDiQD.js} +177 -24
  17. package/dist/gameplay.d.ts +1 -1
  18. package/dist/gameplay.js +1 -1
  19. package/dist/index.d.ts +97 -6
  20. package/dist/index.js +7 -7
  21. package/dist/{loader-D8n7TU8W.js → loader-DEe272nY.js} +900 -2
  22. package/dist/{loader-TvkRFbyL.d.ts → loader-DolLJWJn.d.ts} +13 -1
  23. package/dist/net.d.ts +2 -2
  24. package/dist/net.js +1 -1
  25. package/dist/pathfinding-_fGrCFmH.d.ts +28 -0
  26. package/dist/{physics-2d-DqdVp1bt.js → physics-2d-BXmu2i7W.js} +11 -3
  27. package/dist/{physics-3d-BP0DZb_1.js → physics-3d-ClxP6Uv7.js} +121 -17
  28. package/dist/react.d.ts +1 -1
  29. package/dist/react.js +1 -1
  30. package/dist/{register-BSu2dWGC.js → register-CNh4FlbD.js} +104 -16
  31. package/dist/{register-Da3hXh2H.js → register-D3yx8D4r.js} +228 -16
  32. package/dist/{registry-WWcQcfMr.js → registry-CF70EArN.js} +55 -3
  33. package/dist/{replay-BCMK_VRP.d.ts → replay-C5x2vPF5.d.ts} +2 -2
  34. package/dist/{replay-BlNuIDdg.js → replay-DlgHItNv.js} +57 -197
  35. package/dist/{split-screen-CL5Yvxse.js → split-screen-CBM9wcMX.js} +3 -3
  36. package/dist/{split-screen-BQ3tAsf-.d.ts → split-screen-D7OopelJ.d.ts} +2 -2
  37. package/dist/{src-DFpXBMJN.js → src-CH00_JsR.js} +1 -1
  38. package/dist/{teardown-CTTwhWSe.js → teardown-C7qP-dcC.js} +1 -1
  39. package/dist/{test-BeZ95pqw.js → test-BMkg8zMV.js} +29 -16
  40. package/dist/test.d.ts +45 -4
  41. package/dist/test.js +2 -2
  42. package/dist/vite.js +2 -2
  43. package/editor/assets/{agent8-DSJries_.js → agent8-m5mtAO_A.js} +1 -1
  44. package/editor/assets/{debug-D15Wi5TO.js → debug-CPhzCT8f.js} +1 -1
  45. package/editor/assets/{index-BjC88k97.js → index-D422P4kW.js} +92 -92
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +91 -0
  49. package/skills/incanto-3d-models.md +1 -1
  50. package/skills/incanto-assets.md +10 -1
  51. package/skills/incanto-audio.md +21 -13
  52. package/skills/incanto-behaviors-and-scripts.md +44 -3
  53. package/skills/incanto-building-2d-games.md +32 -4
  54. package/skills/incanto-building-3d-games.md +1 -1
  55. package/skills/incanto-editor.md +1 -1
  56. package/skills/incanto-gameplay-behaviors.md +39 -2
  57. package/skills/incanto-hud.md +5 -3
  58. package/skills/incanto-localization.md +7 -0
  59. package/skills/incanto-node-reference.md +12 -0
  60. package/skills/incanto-physics-and-input.md +23 -2
  61. package/skills/incanto-scene-json-authoring.md +6 -1
  62. package/skills/incanto-verifying-your-game.md +31 -1
  63. package/templates-app/beacon-isle-3d/package.json +1 -1
  64. package/templates-app/platformer-2d/package.json +1 -1
  65. package/templates-app/star-survivor/package.json +1 -1
  66. package/templates-app/tps-3d/package.json +1 -1
  67. package/templates-app/village-quest-3d/package.json +1 -1
  68. package/dist/particle-sim-C5OfBbmU.d.ts +0 -77
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
3
  import { n as jsonEquals, t as jsonClone } from "./json-CwwhxQgb.js";
4
- import { c as mergeStaticSignals, i as getNodeSchema, r as createNode, s as mergeStaticProps, t as applySchemaProps } from "./registry-WWcQcfMr.js";
4
+ import { c as mergeStaticSignals, i as getNodeSchema, o as getNodeType, r as createNode, s as mergeStaticProps, t as applySchemaProps } from "./registry-CF70EArN.js";
5
5
  //#region src/core/signal.ts
6
6
  var Signal = class {
7
7
  connections = [];
@@ -166,6 +166,196 @@ function parseNodePath(path) {
166
166
  };
167
167
  }
168
168
  //#endregion
169
+ //#region src/core/node-refs.ts
170
+ /**
171
+ * Node-path PROPS, resolved against scene JSON.
172
+ *
173
+ * `Chase.target`, `Camera2D.follow`, `Spawner.prefab`, `Joint3D.target`,
174
+ * `CharacterController3D.skinPath`, `Foliage3D.terrain` … each holds a path to
175
+ * another node, and nothing checked any of them. A connection whose end does not
176
+ * resolve is a hard `DANGLING_CONNECTION` at load; a PROP that does not resolve
177
+ * is nothing at all — `getNodeOrNull` returns null, the scene loads, the enemy
178
+ * never chases, and no error is ever printed.
179
+ *
180
+ * That makes it exactly the class `auditScene` exists for: loads fine, plays
181
+ * wrong. The editor uses the same resolver so the two cannot drift.
182
+ *
183
+ * The grammar comes from `parseNodePath` — the loader's own parser — so a path
184
+ * this module accepts is one the engine accepts.
185
+ */
186
+ function nodeAt(root, path) {
187
+ let node = root;
188
+ for (const index of path) {
189
+ node = node?.children?.[index];
190
+ if (!node) return null;
191
+ }
192
+ return node ?? null;
193
+ }
194
+ function byName(root, name) {
195
+ const found = [];
196
+ const walk = (node, at) => {
197
+ if (node.name === name) found.push(at);
198
+ (node.children ?? []).forEach((child, i) => {
199
+ walk(child, [...at, i]);
200
+ });
201
+ };
202
+ walk(root, []);
203
+ return found;
204
+ }
205
+ /**
206
+ * Resolve `value` from the node at `owner`, the way the engine would.
207
+ *
208
+ * Note the asymmetry with connections, whose paths resolve from the SCENE ROOT:
209
+ * a prop's path resolves from the node holding it, which is why `'../Skin'` is
210
+ * the documented default of `CharacterController3D.skinPath` and would mean
211
+ * nothing in a connection.
212
+ */
213
+ function resolveRefInJson(root, owner, value) {
214
+ if (!root) return {
215
+ ok: false,
216
+ problem: {
217
+ kind: "noMatch",
218
+ name: value
219
+ }
220
+ };
221
+ let parsed;
222
+ try {
223
+ parsed = parseNodePath(value);
224
+ } catch (error) {
225
+ return {
226
+ ok: false,
227
+ problem: {
228
+ kind: "grammar",
229
+ message: error instanceof Error ? error.message : String(error)
230
+ }
231
+ };
232
+ }
233
+ if (parsed.kind === "unique") {
234
+ const found = byName(root, parsed.name);
235
+ if (found.length === 1) return {
236
+ ok: true,
237
+ path: found[0]
238
+ };
239
+ if (found.length === 0) return {
240
+ ok: false,
241
+ problem: {
242
+ kind: "noMatch",
243
+ name: parsed.name
244
+ }
245
+ };
246
+ return {
247
+ ok: false,
248
+ problem: {
249
+ kind: "ambiguous",
250
+ name: parsed.name,
251
+ count: found.length
252
+ }
253
+ };
254
+ }
255
+ let at;
256
+ let segments = parsed.segments;
257
+ if (parsed.kind === "absolute") {
258
+ const first = segments[0] ?? "";
259
+ if (first !== root.name && first !== "root") return {
260
+ ok: false,
261
+ problem: {
262
+ kind: "wrongRoot",
263
+ first,
264
+ root: String(root.name ?? "")
265
+ }
266
+ };
267
+ at = [];
268
+ segments = segments.slice(1);
269
+ } else at = [...owner];
270
+ for (const segment of segments) {
271
+ if (segment === "..") {
272
+ if (at.length === 0) return {
273
+ ok: false,
274
+ problem: { kind: "aboveRoot" }
275
+ };
276
+ at.pop();
277
+ continue;
278
+ }
279
+ const here = nodeAt(root, at);
280
+ const index = (here?.children ?? []).findIndex((child) => child.name === segment);
281
+ if (index < 0) return {
282
+ ok: false,
283
+ problem: {
284
+ kind: "noChild",
285
+ parent: String(here?.name ?? "?"),
286
+ segment
287
+ }
288
+ };
289
+ at.push(index);
290
+ }
291
+ return {
292
+ ok: true,
293
+ path: at
294
+ };
295
+ }
296
+ /** The problem in English, for `incanto-check` and as the editor's base text. */
297
+ function describeRefProblem(problem, value) {
298
+ switch (problem.kind) {
299
+ case "grammar": return problem.message;
300
+ case "noMatch": return `'%${problem.name}' matches no node in this scene.`;
301
+ case "ambiguous": return `'%${problem.name}' matches ${problem.count} nodes — a %name must be unique.`;
302
+ case "wrongRoot": return `'${value}' starts at '${problem.first}', but the scene root is '${problem.root}' (or write '/root/…').`;
303
+ case "noChild": return `'${value}' leads nowhere — '${problem.parent}' has no child named '${problem.segment}'.`;
304
+ case "aboveRoot": return `'${value}' walks above the scene root.`;
305
+ }
306
+ }
307
+ /** Every prop on this node declared `nodePath: true`, per bag. */
308
+ function refProps(node) {
309
+ const collect = (schema) => Object.entries(schema).filter(([, def]) => def?.nodePath).map(([key]) => key);
310
+ let props = [];
311
+ if (node.type) try {
312
+ props = collect(getNodeSchema(node.type));
313
+ } catch {}
314
+ const name = node.script?.name;
315
+ return {
316
+ props,
317
+ script: name ? collect(behaviorSchema(name)) : []
318
+ };
319
+ }
320
+ /**
321
+ * Scene lints for node-path props that point at nothing.
322
+ *
323
+ * An EMPTY value is not reported: `''` is the default of most of these props and
324
+ * means "not set", which is a choice rather than a typo.
325
+ */
326
+ function nodeRefWarnings(scene) {
327
+ const root = scene.root;
328
+ if (!root) return [];
329
+ const warnings = [];
330
+ const walk = (node, at, path) => {
331
+ const keys = refProps(node);
332
+ const bags = [[
333
+ node.props,
334
+ keys.props,
335
+ ""
336
+ ], [
337
+ node.script?.props,
338
+ keys.script,
339
+ `${node.script?.name ?? "script"}.`
340
+ ]];
341
+ for (const [bag, propKeys, prefix] of bags) {
342
+ if (!bag) continue;
343
+ for (const key of propKeys) {
344
+ const value = bag[key];
345
+ if (typeof value !== "string" || value === "") continue;
346
+ const result = resolveRefInJson(root, at, value);
347
+ if (result.ok) continue;
348
+ warnings.push(`${path}: ${prefix}${key} — ${describeRefProblem(result.problem, value)} The scene loads and the prop does nothing.`);
349
+ }
350
+ }
351
+ (node.children ?? []).forEach((child, i) => {
352
+ walk(child, [...at, i], `${path}/${child.name ?? "?"}`);
353
+ });
354
+ };
355
+ walk(root, [], String(root.name ?? "root"));
356
+ return warnings;
357
+ }
358
+ //#endregion
169
359
  //#region src/core/diagnostics.ts
170
360
  function diagnose(engine, level, ...parts) {
171
361
  engine?.log[level](...parts);
@@ -559,6 +749,198 @@ function uniqueSiblingName(requested, siblings, self) {
559
749
  return `${stem}${counter}`;
560
750
  }
561
751
  //#endregion
752
+ //#region src/core/scene/handler-collision.ts
753
+ /**
754
+ * A handler name that means two different things, resolved silently.
755
+ *
756
+ * `wireConnections` looks the handler up on the node FIRST and on the behavior
757
+ * second — "Node methods win", which is right when only one of them has it and
758
+ * a coin flip when both do. A node carries 62 public methods before any adapter
759
+ * registers more (`stop`, `play`, `show`, `clear`, `say`, `advance`, `focus`,
760
+ * `next`, `resolve`, `start`, `free`…), and every one of those is a name an
761
+ * author would give a handler.
762
+ *
763
+ * Measured — an `Enemy` script on an `AnimatedSprite2D` with a `stop()` that
764
+ * means "stop chasing", wired from a trigger:
765
+ *
766
+ * ```
767
+ * loaded with NO error
768
+ * behavior.chasing after the signal = true
769
+ * ```
770
+ *
771
+ * `AnimatedSprite2D.stop()` ran instead: the enemy froze mid-animation and kept
772
+ * coming. The wire fired, the handler existed, and the wrong one was called.
773
+ *
774
+ * Only methods the author WROTE count. Every behavior inherits `update`,
775
+ * `onReady` and friends from the base class, and those collide with the node's
776
+ * by construction — flagging them would be a false alarm on a name nobody would
777
+ * wire a signal to anyway.
778
+ */
779
+ function ambiguousHandler(node, handler) {
780
+ const behavior = node.behavior;
781
+ if (!behavior) return false;
782
+ if (typeof node[handler] !== "function") return false;
783
+ return declaredOnSubclass(behavior, handler);
784
+ }
785
+ /** Is `name` a method this behavior's own class defines, not one it inherits? */
786
+ function declaredOnSubclass(behavior, name) {
787
+ let proto = Object.getPrototypeOf(behavior);
788
+ while (proto && proto !== Behavior.prototype) {
789
+ const own = Object.getOwnPropertyDescriptor(proto, name);
790
+ if (own && typeof own.value === "function") return true;
791
+ proto = Object.getPrototypeOf(proto);
792
+ }
793
+ return false;
794
+ }
795
+ /** The error `wireConnections` throws when a handler names both. */
796
+ function ambiguousHandlerError(node, handler, script, to) {
797
+ return new IncantoError("AMBIGUOUS_HANDLER", `Connection → '${to}' names handler '${handler}', which is BOTH a method of '${node.constructor.typeName}' and one your behavior '${typeof script === "string" ? script : "?"}' defines. The node's wins, so the behavior's '${handler}' would never run and nothing would say so. Rename the behavior's method (or drop it, if the node's is the one you meant).`, {
798
+ path: node.getPath(),
799
+ nodeType: node.constructor.typeName,
800
+ uid: node.uid ?? void 0
801
+ });
802
+ }
803
+ /**
804
+ * The same collision, as far as a scene FILE can see it.
805
+ *
806
+ * The load-time error needs the real behavior class, which only the game has —
807
+ * `incanto check` loads scripts as stubs and cannot know what methods they
808
+ * declare. But it CAN see that the handler is a method of the node's own type
809
+ * and that the node carries a script, and that pair is the whole hazard: if the
810
+ * script also defines that name, the node's wins and the script's never runs.
811
+ *
812
+ * Deliberately conditional on the script. `"handler": "play"` on a scriptless
813
+ * `AudioPlayer` is the intended wiring and appears 53 times in the shipped
814
+ * corpus; the pair this warns about appears zero times.
815
+ */
816
+ function handlerShadowWarnings(scene) {
817
+ const root = scene.root;
818
+ if (!root) return [];
819
+ const out = [];
820
+ for (const raw of scene.connections ?? []) {
821
+ const conn = raw;
822
+ if (!conn || typeof conn.to !== "string" || typeof conn.handler !== "string") continue;
823
+ const found = resolveRefInJson(root, [], conn.to);
824
+ if (!found.ok) continue;
825
+ let node = root;
826
+ for (const i of found.path) node = node?.children?.[i];
827
+ const script = node?.script?.name;
828
+ if (!node?.type || !script) continue;
829
+ if (!nodeTypeMethods(node.type).has(conn.handler)) continue;
830
+ out.push(`${conn.to}: handler '${conn.handler}' is a method of ${node.type} itself, and this node also runs the script '${script}'. The node's method WINS — if '${script}' defines '${conn.handler}' too, that one never runs and nothing says so.`);
831
+ }
832
+ return out;
833
+ }
834
+ /** Every public method a registered node type answers to, including inherited. */
835
+ function nodeTypeMethods(type) {
836
+ const cached = methodCache.get(type);
837
+ if (cached) return cached;
838
+ const out = /* @__PURE__ */ new Set();
839
+ let proto;
840
+ try {
841
+ proto = getNodeType(type).prototype;
842
+ } catch {
843
+ methodCache.set(type, out);
844
+ return out;
845
+ }
846
+ while (proto && proto !== Object.prototype) {
847
+ for (const key of Object.getOwnPropertyNames(proto)) {
848
+ if (key.startsWith("_") || key === "constructor") continue;
849
+ if (typeof Object.getOwnPropertyDescriptor(proto, key)?.value === "function") out.add(key);
850
+ }
851
+ proto = Object.getPrototypeOf(proto);
852
+ }
853
+ methodCache.set(type, out);
854
+ return out;
855
+ }
856
+ const methodCache = /* @__PURE__ */ new Map();
857
+ /** Every code the engine can ever receive, keyboard + mouse + gamepad. */
858
+ const INPUT_CODES = new Set([
859
+ ...[..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"].map((c) => `Key${c}`),
860
+ ...Array.from({ length: 10 }, (_, i) => `Digit${i}`),
861
+ ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
862
+ "NumpadAdd",
863
+ "NumpadSubtract",
864
+ "NumpadMultiply",
865
+ "NumpadDivide",
866
+ "NumpadDecimal",
867
+ "NumpadEnter",
868
+ ...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
869
+ "ArrowUp",
870
+ "ArrowDown",
871
+ "ArrowLeft",
872
+ "ArrowRight",
873
+ "Space",
874
+ "Enter",
875
+ "Escape",
876
+ "Tab",
877
+ "Backspace",
878
+ "Delete",
879
+ "Insert",
880
+ "Home",
881
+ "End",
882
+ "PageUp",
883
+ "PageDown",
884
+ "CapsLock",
885
+ "ShiftLeft",
886
+ "ShiftRight",
887
+ "ControlLeft",
888
+ "ControlRight",
889
+ "AltLeft",
890
+ "AltRight",
891
+ "MetaLeft",
892
+ "MetaRight",
893
+ "ContextMenu",
894
+ "Backquote",
895
+ "Minus",
896
+ "Equal",
897
+ "BracketLeft",
898
+ "BracketRight",
899
+ "Backslash",
900
+ "Semicolon",
901
+ "Quote",
902
+ "Comma",
903
+ "Period",
904
+ "Slash",
905
+ "IntlBackslash",
906
+ "IntlRo",
907
+ "IntlYen",
908
+ ...Array.from({ length: 5 }, (_, i) => `Mouse${i}`),
909
+ ...Array.from({ length: 17 }, (_, i) => `Pad${i}`)
910
+ ]);
911
+ /** The one a typo probably meant, or null when nothing is close enough. */
912
+ function nearestCode(written) {
913
+ const lower = written.toLowerCase();
914
+ if (/^[a-z]$/.test(lower)) return `Key${lower.toUpperCase()}`;
915
+ if (/^[0-9]$/.test(written)) return `Digit${written}`;
916
+ if (lower === "shift" || lower === "ctrl" || lower === "control" || lower === "alt") {
917
+ const side = lower === "ctrl" ? "Control" : lower[0]?.toUpperCase() + lower.slice(1);
918
+ return `${side}Left" or "${side}Right`;
919
+ }
920
+ if (lower === "esc") return "Escape";
921
+ if (lower === "return") return "Enter";
922
+ if (lower === "up" || lower === "down" || lower === "left" || lower === "right") return `Arrow${lower[0]?.toUpperCase()}${lower.slice(1)}`;
923
+ let best = null;
924
+ let bestScore = 3;
925
+ for (const code of INPUT_CODES) {
926
+ const d = distance$1(lower, code.toLowerCase());
927
+ if (d < bestScore) {
928
+ bestScore = d;
929
+ best = code;
930
+ }
931
+ }
932
+ return best;
933
+ }
934
+ function distance$1(a, b) {
935
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
936
+ for (let j = 0; j <= b.length; j++) rows[0][j] = j;
937
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
938
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
939
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
940
+ }
941
+ return rows[a.length][b.length];
942
+ }
943
+ //#endregion
562
944
  //#region src/core/input.ts
563
945
  /** Keys typed into editable elements belong to the page UI, not the game. */
564
946
  function isEditableTarget(target) {
@@ -1026,6 +1408,21 @@ const DIRECTIONS = [
1026
1408
  "left",
1027
1409
  "right"
1028
1410
  ];
1411
+ /**
1412
+ * Refuse a key code the engine can never receive.
1413
+ *
1414
+ * The map is looked up by EXACT string against what the browser hands us, so
1415
+ * `"W"`, `"space"` and `"Shift"` — the three most natural things to write — bind
1416
+ * to nothing at all. They loaded clean, passed `incanto check`, and left the
1417
+ * player without that control, with nothing anywhere to say so.
1418
+ */
1419
+ function checkCodes(action, where, keys) {
1420
+ for (const key of keys) {
1421
+ if (typeof key !== "string" || INPUT_CODES.has(key)) continue;
1422
+ const guess = nearestCode(key);
1423
+ throw new IncantoError("BAD_FORMAT", `Input action '${action}'${where}: '${key}' is not a key code, so nothing can ever press it${guess ? ` — did you mean "${guess}"?` : ""}. Codes are KeyboardEvent.code values (KeyW, Space, ArrowLeft, ShiftLeft), plus Mouse0..4 and Pad0..16.`, { prop: "keys" });
1424
+ }
1425
+ }
1029
1426
  function parseInputDecls(decls) {
1030
1427
  const out = /* @__PURE__ */ new Map();
1031
1428
  for (const [name, raw] of Object.entries(decls)) {
@@ -1034,6 +1431,7 @@ function parseInputDecls(decls) {
1034
1431
  const keys = decl.keys;
1035
1432
  if (!Array.isArray(keys) || keys.some((k) => typeof k !== "string")) throw new IncantoError("BAD_FORMAT", `Input action '${name}': button "keys" must be an array of KeyboardEvent.code strings.`);
1036
1433
  if (decl.touch !== void 0 && decl.touch !== "button") throw new IncantoError("BAD_FORMAT", `Input action '${name}': button "touch" must be 'button', got ${JSON.stringify(decl.touch)}.`);
1434
+ checkCodes(name, "", keys);
1037
1435
  out.set(name, {
1038
1436
  type: "button",
1039
1437
  keys: [...keys],
@@ -1049,6 +1447,7 @@ function parseInputDecls(decls) {
1049
1447
  }
1050
1448
  if (decl.touch !== void 0 && decl.touch !== "joystick") throw new IncantoError("BAD_FORMAT", `Input action '${name}': vector2 "touch" must be 'joystick', got ${JSON.stringify(decl.touch)}.`);
1051
1449
  const d = dirs;
1450
+ for (const dir of DIRECTIONS) checkCodes(name, ` (${dir})`, d[dir]);
1052
1451
  out.set(name, {
1053
1452
  type: "vector2",
1054
1453
  keys: {
@@ -1729,6 +2128,484 @@ function computeViewport(canvasW, canvasH, viewport) {
1729
2128
  };
1730
2129
  }
1731
2130
  //#endregion
2131
+ //#region src/core/scene/asset-refs.ts
2132
+ /**
2133
+ * Hard-check every `$assetKey` a scene names, at LOAD.
2134
+ *
2135
+ * The check already existed and nothing routed to it at author time:
2136
+ * `AssetStore2D.resolve` throws `UNKNOWN_ASSET` for an undeclared key AND for a
2137
+ * value that is not a `$ref`, and it runs inside the renderer, in a browser,
2138
+ * when the texture is first needed. Headless the scene loaded clean.
2139
+ *
2140
+ * Measured before this existed, on a scene with four sprites — one good, one
2141
+ * with a typo'd key, one with a raw URL, one with a bare word:
2142
+ *
2143
+ * ```
2144
+ * ok g.scene.json
2145
+ * warn: $hero → /hero.png is not in the project …
2146
+ * 1/1 scene(s) valid
2147
+ * ```
2148
+ *
2149
+ * The only one it mentioned is the one that IS declared. The other three draw
2150
+ * nothing, and `incanto-assets.md` promises that a raw URL here "hard-fails".
2151
+ */
2152
+ /** The `assets{}` entry types a prop may point at, and what to call them. */
2153
+ const KIND_LABEL = {
2154
+ texture: "an image",
2155
+ spritesheet: "a spritesheet (with frameWidth/frameHeight)",
2156
+ model: "a GLB/glTF/VRM model",
2157
+ animation: "a GLB's animation clips"
2158
+ };
2159
+ /**
2160
+ * Throws `UNKNOWN_ASSET` for any asset prop this node cannot resolve.
2161
+ *
2162
+ * @param props the node's own prop values
2163
+ * @param schema the merged prop schema for its type
2164
+ * @param assets the scene's `assets{}` block
2165
+ * @param nodeName for the message — a scene has many nodes
2166
+ */
2167
+ function validateAssetRefs(props, schema, assets, nodeName) {
2168
+ for (const [key, def] of Object.entries(schema)) {
2169
+ const spec = def.asset;
2170
+ if (!spec) continue;
2171
+ const value = props[key];
2172
+ if (typeof value !== "string" || value === "") continue;
2173
+ if (!value.startsWith("$")) {
2174
+ if (!spec.refOnly) continue;
2175
+ throw new IncantoError("UNKNOWN_ASSET", `'${nodeName}.${key}' takes a "$assetKey" reference, not ${/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//") ? "a URL" : `'${value}'`}. Declare the asset — "assets": { "${suggestKey(value)}": { "type": "${spec.kind}", "url": "…" } } — and point the prop at "$${suggestKey(value)}".`, { prop: key });
2176
+ }
2177
+ const name = value.slice(1);
2178
+ const entry = assets?.[name];
2179
+ if (!entry) {
2180
+ const declared = Object.keys(assets ?? {});
2181
+ throw new IncantoError("UNKNOWN_ASSET", `'${nodeName}.${key}' names asset '${value}', which the scene does not declare. ` + (declared.length > 0 ? `Declared: [${declared.join(", ")}].` : "The scene has no \"assets\" block."), {
2182
+ prop: key,
2183
+ validOptions: declared
2184
+ });
2185
+ }
2186
+ if (entry.type !== spec.kind) throw new IncantoError("UNKNOWN_ASSET", `'${nodeName}.${key}' needs ${KIND_LABEL[spec.kind] ?? spec.kind} — asset '${name}' is declared as "${String(entry.type)}". Change the entry's "type" to "${spec.kind}".`, {
2187
+ prop: key,
2188
+ validOptions: [spec.kind]
2189
+ });
2190
+ }
2191
+ }
2192
+ /** A plausible asset key for the message, from whatever they wrote. */
2193
+ function suggestKey(value) {
2194
+ const last = value.split(/[/\\]/).pop() ?? value;
2195
+ const stem = last.split("?")[0]?.replace(/\.[a-z0-9]+$/i, "") ?? last;
2196
+ return stem !== "" ? stem : "myAsset";
2197
+ }
2198
+ //#endregion
2199
+ //#region src/core/scene/environment.ts
2200
+ const SKY_KEYS = [
2201
+ "type",
2202
+ "sunPosition",
2203
+ "elevationDeg",
2204
+ "azimuthDeg",
2205
+ "turbidity",
2206
+ "rayleigh"
2207
+ ];
2208
+ const FOG_KEYS = [
2209
+ "color",
2210
+ "near",
2211
+ "far"
2212
+ ];
2213
+ const CLOUD_KEYS = [
2214
+ "coverage",
2215
+ "density",
2216
+ "base",
2217
+ "top",
2218
+ "color",
2219
+ "shadeColor",
2220
+ "speed",
2221
+ "scale"
2222
+ ];
2223
+ const BLOOM_KEYS = ["threshold", "strength"];
2224
+ const POST_KEYS = [
2225
+ "vignette",
2226
+ "saturation",
2227
+ "contrast"
2228
+ ];
2229
+ const SHADOW_KEYS = [
2230
+ "mapSize",
2231
+ "radius",
2232
+ "static"
2233
+ ];
2234
+ const SHADOW_MAP_SIZES = [1024, 2048];
2235
+ const DEFAULT_TURBIDITY = 2;
2236
+ const DEFAULT_RAYLEIGH = 1;
2237
+ const DEFAULT_FOG_NEAR = 50;
2238
+ const DEFAULT_FOG_FAR = 800;
2239
+ /** Fallback haze when fog is declared without a sky to derive a horizon from. */
2240
+ const DEFAULT_FOG_COLOR = "#cfd8e0";
2241
+ const DEG2RAD = Math.PI / 180;
2242
+ /** Parse + hard-validate the 3D slice of a scene's `environment` header. */
2243
+ function parseEnvironment3D(env) {
2244
+ return {
2245
+ exposure: parseExposure(env?.exposure),
2246
+ sky: parseSky(env?.sky),
2247
+ fog: parseFog(env?.fog, env?.sky !== void 0),
2248
+ clouds: parseClouds(env?.clouds),
2249
+ bloom: parseBloom(env?.bloom),
2250
+ post: parsePost(env?.post),
2251
+ shadows: parseShadows(env?.shadows)
2252
+ };
2253
+ }
2254
+ /**
2255
+ * elevation/azimuth (degrees) → unit direction. Matches three's Sky example
2256
+ * (`Vector3.setFromSphericalCoords`): azimuth 0 = +Z, 90 = +X; elevation 90 =
2257
+ * straight up.
2258
+ */
2259
+ function sunDirectionFromElevationAzimuth(elevationDeg, azimuthDeg) {
2260
+ const phi = (90 - elevationDeg) * DEG2RAD;
2261
+ const theta = azimuthDeg * DEG2RAD;
2262
+ return [
2263
+ Math.sin(phi) * Math.sin(theta),
2264
+ Math.cos(phi),
2265
+ Math.sin(phi) * Math.cos(theta)
2266
+ ];
2267
+ }
2268
+ /** The sky's sun as a UNIT vector — what Water3D/Foliage3D uniforms consume. */
2269
+ function sunDirectionFromSky(sky) {
2270
+ const [x, y, z] = sky.sunPosition;
2271
+ const len = Math.hypot(x, y, z) || 1;
2272
+ return [
2273
+ x / len,
2274
+ y / len,
2275
+ z / len
2276
+ ];
2277
+ }
2278
+ /**
2279
+ * A horizon-ish haze color derived from the sky config — the default fog
2280
+ * color. Cheap model, judged by eye: clear skies haze blue-grey, turbid skies
2281
+ * whiten, and a low sun warms the band toward amber.
2282
+ */
2283
+ function horizonColorFromSky(sky) {
2284
+ const clear = [
2285
+ 191,
2286
+ 213,
2287
+ 232
2288
+ ];
2289
+ const hazy = [
2290
+ 233,
2291
+ 228,
2292
+ 217
2293
+ ];
2294
+ const warm = [
2295
+ 242,
2296
+ 201,
2297
+ 150
2298
+ ];
2299
+ const t = clamp01((sky.turbidity - DEFAULT_TURBIDITY) / 8);
2300
+ const dir = sunDirectionFromSky(sky);
2301
+ const w = clamp01((18 - Math.asin(clamp(dir[1], -1, 1)) / DEG2RAD) / 18) * .8;
2302
+ const mix = (i) => Math.round(lerp(lerp(clear[i], hazy[i], t), warm[i], w));
2303
+ return `#${[
2304
+ mix(0),
2305
+ mix(1),
2306
+ mix(2)
2307
+ ].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
2308
+ }
2309
+ function parseExposure(value) {
2310
+ if (value === void 0) return 1;
2311
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new IncantoError("BAD_FORMAT", `environment.exposure must be a finite number > 0 (tone-mapping exposure, default 1), got ${JSON.stringify(value)}.`, { prop: "exposure" });
2312
+ return value;
2313
+ }
2314
+ function parseSky(value) {
2315
+ if (value === void 0) return null;
2316
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.sky must be an object ({ type?: "atmosphere", sunPosition? | elevationDeg?+azimuthDeg?, turbidity?, rayleigh? }), got ${JSON.stringify(value)}.`, { prop: "sky" });
2317
+ const sky = value;
2318
+ for (const key of Object.keys(sky)) if (!SKY_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.sky has unknown key '${key}'. Valid keys: [${SKY_KEYS.join(", ")}].`, {
2319
+ prop: "sky",
2320
+ validOptions: SKY_KEYS
2321
+ });
2322
+ if (sky.type !== void 0 && sky.type !== "atmosphere") throw new IncantoError("BAD_FORMAT", `environment.sky.type must be 'atmosphere' (the only sky type so far), got ${JSON.stringify(sky.type)}.`, {
2323
+ prop: "sky",
2324
+ validOptions: ["atmosphere"]
2325
+ });
2326
+ const hasAngles = sky.elevationDeg !== void 0 || sky.azimuthDeg !== void 0;
2327
+ if (sky.sunPosition !== void 0 && hasAngles) throw new IncantoError("BAD_FORMAT", `environment.sky takes sunPosition OR elevationDeg/azimuthDeg, not both.`, {
2328
+ prop: "sky",
2329
+ validOptions: ["sunPosition", "elevationDeg+azimuthDeg"]
2330
+ });
2331
+ let sunPosition;
2332
+ if (sky.sunPosition !== void 0) {
2333
+ const sp = sky.sunPosition;
2334
+ if (!Array.isArray(sp) || sp.length !== 3 || !sp.every((v) => typeof v === "number" && Number.isFinite(v)) || Math.hypot(sp[0], sp[1], sp[2]) === 0) throw new IncantoError("BAD_FORMAT", `environment.sky.sunPosition must be a non-zero [x, y, z] vector, got ${JSON.stringify(sp)}.`, { prop: "sky" });
2335
+ sunPosition = [
2336
+ sp[0],
2337
+ sp[1],
2338
+ sp[2]
2339
+ ];
2340
+ } else {
2341
+ const elevation = numberOr(sky.elevationDeg, 32, "sky.elevationDeg");
2342
+ const azimuth = numberOr(sky.azimuthDeg, 135, "sky.azimuthDeg");
2343
+ if (elevation < -90 || elevation > 90) throw new IncantoError("BAD_FORMAT", `environment.sky.elevationDeg must be in [-90, 90] (degrees above the horizon), got ${elevation}.`, { prop: "sky" });
2344
+ sunPosition = sunDirectionFromElevationAzimuth(elevation, azimuth);
2345
+ }
2346
+ const turbidity = numberOr(sky.turbidity, DEFAULT_TURBIDITY, "sky.turbidity");
2347
+ if (turbidity <= 0) throw new IncantoError("BAD_FORMAT", `environment.sky.turbidity must be > 0 (atmospheric haze; 2 ≈ clear day), got ${turbidity}.`, { prop: "sky" });
2348
+ const rayleigh = numberOr(sky.rayleigh, DEFAULT_RAYLEIGH, "sky.rayleigh");
2349
+ if (rayleigh < 0) throw new IncantoError("BAD_FORMAT", `environment.sky.rayleigh must be >= 0 (Rayleigh scattering; 1 ≈ earth-like), got ${rayleigh}.`, { prop: "sky" });
2350
+ return {
2351
+ sunPosition,
2352
+ turbidity,
2353
+ rayleigh
2354
+ };
2355
+ }
2356
+ function parseFog(value, hasSky) {
2357
+ if (value === void 0) return null;
2358
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.fog must be an object ({ color?, near?, far? }), got ${JSON.stringify(value)}.`, { prop: "fog" });
2359
+ const fog = value;
2360
+ for (const key of Object.keys(fog)) if (!FOG_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.fog has unknown key '${key}'. Valid keys: [${FOG_KEYS.join(", ")}].`, {
2361
+ prop: "fog",
2362
+ validOptions: FOG_KEYS
2363
+ });
2364
+ if (fog.color !== void 0 && typeof fog.color !== "string") throw new IncantoError("BAD_FORMAT", `environment.fog.color must be a hex color string, got ${JSON.stringify(fog.color)}.`, { prop: "fog" });
2365
+ const near = numberOr(fog.near, DEFAULT_FOG_NEAR, "fog.near");
2366
+ const far = numberOr(fog.far, DEFAULT_FOG_FAR, "fog.far");
2367
+ if (near < 0) throw new IncantoError("BAD_FORMAT", `environment.fog.near must be >= 0 meters, got ${near}.`, { prop: "fog" });
2368
+ if (far <= near) throw new IncantoError("BAD_FORMAT", `environment.fog.far must be > near (got near ${near}, far ${far}).`, { prop: "fog" });
2369
+ return {
2370
+ color: fog.color ?? (hasSky ? "" : DEFAULT_FOG_COLOR),
2371
+ near,
2372
+ far
2373
+ };
2374
+ }
2375
+ function parseClouds(value) {
2376
+ if (value === void 0) return null;
2377
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.clouds must be an object ({ coverage?, density?, base?, top?, color?, shadeColor?, speed?, scale? }), got ${JSON.stringify(value)}.`, { prop: "clouds" });
2378
+ const c = value;
2379
+ for (const key of Object.keys(c)) if (!CLOUD_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.clouds has unknown key '${key}'. Valid keys: [${CLOUD_KEYS.join(", ")}].`, {
2380
+ prop: "clouds",
2381
+ validOptions: CLOUD_KEYS
2382
+ });
2383
+ for (const k of ["color", "shadeColor"]) if (c[k] !== void 0 && typeof c[k] !== "string") throw new IncantoError("BAD_FORMAT", `environment.clouds.${k} must be a hex color string, got ${JSON.stringify(c[k])}.`, { prop: "clouds" });
2384
+ const coverage = numberOr(c.coverage, .5, "clouds.coverage");
2385
+ if (coverage < 0 || coverage > 1) throw new IncantoError("BAD_FORMAT", `environment.clouds.coverage must be in [0, 1] (how much sky is cloudy), got ${coverage}.`, { prop: "clouds" });
2386
+ const density = numberOr(c.density, 1, "clouds.density");
2387
+ if (density < 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.density must be >= 0 (optical thickness), got ${density}.`, { prop: "clouds" });
2388
+ const base = numberOr(c.base, 120, "clouds.base");
2389
+ const top = numberOr(c.top, 320, "clouds.top");
2390
+ if (top <= base) throw new IncantoError("BAD_FORMAT", `environment.clouds.top must be > base (got base ${base}, top ${top}).`, { prop: "clouds" });
2391
+ const speed = numberOr(c.speed, 1, "clouds.speed");
2392
+ const scale = numberOr(c.scale, 240, "clouds.scale");
2393
+ if (scale <= 0) throw new IncantoError("BAD_FORMAT", `environment.clouds.scale must be > 0 (feature size in world units), got ${scale}.`, { prop: "clouds" });
2394
+ return {
2395
+ coverage,
2396
+ density,
2397
+ base,
2398
+ top,
2399
+ color: c.color ?? "#ffffff",
2400
+ shadeColor: c.shadeColor ?? "#9fb0c8",
2401
+ speed,
2402
+ scale
2403
+ };
2404
+ }
2405
+ function parseBloom(value) {
2406
+ if (value === void 0) return null;
2407
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.bloom must be an object ({ threshold?, strength? }), got ${JSON.stringify(value)}.`, { prop: "bloom" });
2408
+ const b = value;
2409
+ for (const key of Object.keys(b)) if (!BLOOM_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.bloom has unknown key '${key}'. Valid keys: [${BLOOM_KEYS.join(", ")}].`, {
2410
+ prop: "bloom",
2411
+ validOptions: BLOOM_KEYS
2412
+ });
2413
+ const threshold = numberOr(b.threshold, 1, "bloom.threshold");
2414
+ if (threshold < 0 || threshold > 8) throw new IncantoError("BAD_FORMAT", `environment.bloom.threshold must be in [0, 8] (linear-HDR luminance; 1 = white), got ${threshold}.`, { prop: "bloom" });
2415
+ const strength = numberOr(b.strength, .8, "bloom.strength");
2416
+ if (strength < 0) throw new IncantoError("BAD_FORMAT", `environment.bloom.strength must be >= 0, got ${strength}.`, { prop: "bloom" });
2417
+ return {
2418
+ threshold,
2419
+ strength
2420
+ };
2421
+ }
2422
+ function parsePost(value) {
2423
+ if (value === void 0) return null;
2424
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.post must be an object ({ vignette?, saturation?, contrast? }), got ${JSON.stringify(value)}.`, { prop: "post" });
2425
+ const p = value;
2426
+ for (const key of Object.keys(p)) if (!POST_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.post has unknown key '${key}'. Valid keys: [${POST_KEYS.join(", ")}].`, {
2427
+ prop: "post",
2428
+ validOptions: POST_KEYS
2429
+ });
2430
+ const vignette = numberOr(p.vignette, 0, "post.vignette");
2431
+ if (vignette < 0 || vignette > 1) throw new IncantoError("BAD_FORMAT", `environment.post.vignette must be in [0, 1], got ${vignette}.`, { prop: "post" });
2432
+ const saturation = numberOr(p.saturation, 1, "post.saturation");
2433
+ if (saturation < 0 || saturation > 4) throw new IncantoError("BAD_FORMAT", `environment.post.saturation must be in [0, 4] (1 = neutral), got ${saturation}.`, { prop: "post" });
2434
+ const contrast = numberOr(p.contrast, 1, "post.contrast");
2435
+ if (contrast < .2 || contrast > 3) throw new IncantoError("BAD_FORMAT", `environment.post.contrast must be in [0.2, 3] (1 = neutral), got ${contrast}.`, { prop: "post" });
2436
+ return {
2437
+ vignette,
2438
+ saturation,
2439
+ contrast
2440
+ };
2441
+ }
2442
+ function parseShadows(value) {
2443
+ if (value === void 0) return null;
2444
+ if (value === false) return false;
2445
+ if (value === true) return {
2446
+ mapSize: 2048,
2447
+ radius: 1,
2448
+ static: false
2449
+ };
2450
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new IncantoError("BAD_FORMAT", `environment.shadows must be true, false or an object ({ mapSize?, radius? }), got ${JSON.stringify(value)}.`, { prop: "shadows" });
2451
+ const shadows = value;
2452
+ for (const key of Object.keys(shadows)) if (!SHADOW_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `environment.shadows has unknown key '${key}'. Valid keys: [${SHADOW_KEYS.join(", ")}].`, {
2453
+ prop: "shadows",
2454
+ validOptions: SHADOW_KEYS
2455
+ });
2456
+ const mapSize = shadows.mapSize === void 0 ? 2048 : shadows.mapSize;
2457
+ if (!SHADOW_MAP_SIZES.includes(mapSize)) throw new IncantoError("BAD_FORMAT", `environment.shadows.mapSize must be one of [${SHADOW_MAP_SIZES.join(", ")}], got ${JSON.stringify(shadows.mapSize)}.`, {
2458
+ prop: "shadows",
2459
+ validOptions: SHADOW_MAP_SIZES.map(String)
2460
+ });
2461
+ const radius = numberOr(shadows.radius, 1, "shadows.radius");
2462
+ if (radius < 0) throw new IncantoError("BAD_FORMAT", `environment.shadows.radius must be >= 0, got ${radius}.`, { prop: "shadows" });
2463
+ const staticShadows = shadows.static === void 0 ? false : shadows.static;
2464
+ if (typeof staticShadows !== "boolean") throw new IncantoError("BAD_FORMAT", `environment.shadows.static must be a boolean, got ${JSON.stringify(shadows.static)}.`, { prop: "shadows" });
2465
+ return {
2466
+ mapSize,
2467
+ radius,
2468
+ static: staticShadows
2469
+ };
2470
+ }
2471
+ function numberOr(value, fallback, at) {
2472
+ if (value === void 0) return fallback;
2473
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new IncantoError("BAD_FORMAT", `environment.${at} must be a finite number, got ${JSON.stringify(value)}.`, { prop: at });
2474
+ return value;
2475
+ }
2476
+ function clamp(v, min, max) {
2477
+ return Math.min(Math.max(v, min), max);
2478
+ }
2479
+ function clamp01(v) {
2480
+ return clamp(v, 0, 1);
2481
+ }
2482
+ function lerp(a, b, t) {
2483
+ return a + (b - a) * t;
2484
+ }
2485
+ //#endregion
2486
+ //#region src/core/scene/environment-validate.ts
2487
+ /**
2488
+ * Check a scene's `environment` header at LOAD.
2489
+ *
2490
+ * `parseEnvironment3D` has always thrown good errors — and its only caller was
2491
+ * `Environment3D.apply()`, inside a live renderer, in a browser. So the scene
2492
+ * header's biggest block was the one thing `loadScene` and `incanto-check`
2493
+ * could not check. Measured, every one of these loading clean and reported as
2494
+ * `1/1 scene(s) valid`:
2495
+ *
2496
+ * ```
2497
+ * sky.type = "gradient" (only atmosphere exists) NO ERROR
2498
+ * shadows = "soft" NO ERROR
2499
+ * exposure = "bright" NO ERROR
2500
+ * rendering.pixelRatio = "auto" NO ERROR
2501
+ * a typo'd top-level key: skye NO ERROR
2502
+ * ```
2503
+ *
2504
+ * The last two are the ones that cost a whole session. A typo'd top-level key
2505
+ * means the sky you thought you declared does not exist — no backdrop, no
2506
+ * image-based light, no sun, no message. And `pixelRatio: "auto"` (the
2507
+ * plausible typo for `"device"`) reaches `setPixelRatio("auto")`, three
2508
+ * computes `Math.floor(width * NaN)`, and the canvas becomes 0×0: the game
2509
+ * renders nothing at all.
2510
+ *
2511
+ * Same shape as the asset refs and as `nodePath`/`required` before them — the
2512
+ * rule existed and nothing routed to it at author time.
2513
+ */
2514
+ /**
2515
+ * Top-level blocks the renderer reads.
2516
+ *
2517
+ * Kept in step with the consumers by a test, not by discipline: the first cut
2518
+ * of this list was written by hand and left out `iblIntensity` and `preset`,
2519
+ * which 17 shipped scenes use — so the check rejected the engine's own
2520
+ * examples. A whitelist nobody can verify is a new way to be wrong.
2521
+ */
2522
+ const ENVIRONMENT_KEYS = [
2523
+ "ambient",
2524
+ "background",
2525
+ "bloom",
2526
+ "clouds",
2527
+ "exposure",
2528
+ "fog",
2529
+ "hdri",
2530
+ "iblIntensity",
2531
+ "post",
2532
+ "preset",
2533
+ "rendering",
2534
+ "shadows",
2535
+ "sky",
2536
+ "skybox"
2537
+ ];
2538
+ /** Closest declared key by a cheap edit distance, for "did you mean". */
2539
+ function nearest(key) {
2540
+ const lower = key.toLowerCase();
2541
+ let best = null;
2542
+ let bestScore = 3;
2543
+ for (const known of ENVIRONMENT_KEYS) {
2544
+ const d = distance(lower, known);
2545
+ if (d < bestScore) {
2546
+ bestScore = d;
2547
+ best = known;
2548
+ }
2549
+ }
2550
+ return best;
2551
+ }
2552
+ function distance(a, b) {
2553
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
2554
+ for (let j = 0; j <= b.length; j++) rows[0][j] = j;
2555
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
2556
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
2557
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
2558
+ }
2559
+ return rows[a.length][b.length];
2560
+ }
2561
+ /** `rendering` is read by the renderer, not by `parseEnvironment3D`. */
2562
+ function validateRendering(rendering) {
2563
+ if (rendering === void 0) return;
2564
+ if (typeof rendering !== "object" || rendering === null || Array.isArray(rendering)) throw new IncantoError("BAD_FORMAT", `environment.rendering must be an object ({ antialias?, pixelRatio? }), got ${JSON.stringify(rendering)}.`);
2565
+ const r = rendering;
2566
+ for (const key of Object.keys(r)) {
2567
+ if (key === "antialias" || key === "pixelRatio" || key === "preserveDrawingBuffer") continue;
2568
+ throw new IncantoError("BAD_FORMAT", `environment.rendering has unknown key '${key}'. Valid keys: [antialias, pixelRatio, preserveDrawingBuffer].`, {
2569
+ prop: key,
2570
+ validOptions: [
2571
+ "antialias",
2572
+ "pixelRatio",
2573
+ "preserveDrawingBuffer"
2574
+ ]
2575
+ });
2576
+ }
2577
+ const ratio = r.pixelRatio;
2578
+ if (ratio !== void 0 && ratio !== "device" && typeof ratio !== "number") throw new IncantoError("BAD_FORMAT", `environment.rendering.pixelRatio must be a number or "device", got ${JSON.stringify(ratio)}. Anything else reaches setPixelRatio() as-is and gives the game a 0×0 canvas.`, {
2579
+ prop: "pixelRatio",
2580
+ validOptions: ["device"]
2581
+ });
2582
+ if (typeof ratio === "number" && !(ratio > 0 && Number.isFinite(ratio))) throw new IncantoError("BAD_FORMAT", `environment.rendering.pixelRatio must be greater than 0, got ${JSON.stringify(ratio)}.`, { prop: "pixelRatio" });
2583
+ if (r.antialias !== void 0 && typeof r.antialias !== "boolean") throw new IncantoError("BAD_FORMAT", `environment.rendering.antialias must be true or false, got ${JSON.stringify(r.antialias)}.`, { prop: "antialias" });
2584
+ }
2585
+ /**
2586
+ * Throws `BAD_FORMAT` for anything in `environment` the renderer would reject
2587
+ * or silently drop.
2588
+ *
2589
+ * @param environment the scene's `environment` block
2590
+ * @param dimension only a 3D scene has this contract
2591
+ */
2592
+ function validateEnvironment(environment, dimension) {
2593
+ if (environment === void 0 || environment === null) return;
2594
+ if (typeof environment !== "object" || Array.isArray(environment)) throw new IncantoError("BAD_FORMAT", `Scene "environment" must be an object, got ${JSON.stringify(environment)}.`);
2595
+ const env = environment;
2596
+ validateRendering(env.rendering);
2597
+ if (dimension !== "3d") return;
2598
+ for (const key of Object.keys(env)) {
2599
+ if (ENVIRONMENT_KEYS.includes(key)) continue;
2600
+ const guess = nearest(key);
2601
+ throw new IncantoError("BAD_FORMAT", `environment has unknown key '${key}'${guess ? ` — did you mean "${guess}"?` : ""}. Valid keys: [${ENVIRONMENT_KEYS.join(", ")}].`, {
2602
+ prop: key,
2603
+ validOptions: [...ENVIRONMENT_KEYS]
2604
+ });
2605
+ }
2606
+ parseEnvironment3D(env);
2607
+ }
2608
+ //#endregion
1732
2609
  //#region src/core/scene/network-validate.ts
1733
2610
  /**
1734
2611
  * Hard-check a node's `network` block at LOAD.
@@ -1958,7 +2835,12 @@ function loadScene(json, opts) {
1958
2835
  const sceneJson = json;
1959
2836
  validateHeader(sceneJson);
1960
2837
  validateStrings(sceneJson.strings);
2838
+ validateEnvironment(sceneJson.environment, sceneJson.dimension);
1961
2839
  inheritedStrings = {};
2840
+ loadingAssets = {
2841
+ ...opts?.declaredAssets ?? {},
2842
+ ...sceneJson.assets ?? {}
2843
+ };
1962
2844
  const root = buildNode(sceneJson.root, opts, [], "", sceneJson.constants, Object.keys(sceneJson.orderGroups ?? {}));
1963
2845
  validateUniqueUids(root);
1964
2846
  const tree = new SceneTree();
@@ -1967,6 +2849,7 @@ function loadScene(json, opts) {
1967
2849
  wireConnections(root, sceneJson.connections ?? [], opts?.declareConnectionSignals);
1968
2850
  const collected = inheritedStrings;
1969
2851
  inheritedStrings = null;
2852
+ loadingAssets = null;
1970
2853
  return new Scene(withInheritedStrings(sceneJson, collected), root, tree);
1971
2854
  }
1972
2855
  /**
@@ -1978,6 +2861,15 @@ function loadScene(json, opts) {
1978
2861
  * constants table is passed the way it is.
1979
2862
  */
1980
2863
  let inheritedStrings = null;
2864
+ /**
2865
+ * The `assets{}` table to validate `$ref`s against, or null outside a load.
2866
+ *
2867
+ * NULL, not empty: `buildNodeJson` rebuilds a detached subtree for
2868
+ * `duplicateNode` and for `NetworkSpawner`, and there is no scene header in
2869
+ * either — checking against "no assets" there would reject every sprite in a
2870
+ * duplicated node.
2871
+ */
2872
+ let loadingAssets = null;
1981
2873
  /** Fold one sub-scene's table into the accumulator. */
1982
2874
  function inheritStrings(strings) {
1983
2875
  if (!inheritedStrings || strings === void 0) return;
@@ -2089,6 +2981,10 @@ function parentOf(path) {
2089
2981
  const i = path.lastIndexOf("/");
2090
2982
  return i <= 0 ? "" : path.slice(0, i);
2091
2983
  }
2984
+ /** The merged prop schema of whatever type this node is. */
2985
+ function propsFor(node) {
2986
+ return mergeStaticProps(node.constructor);
2987
+ }
2092
2988
  function applyCommon(node, nj, opts) {
2093
2989
  if (typeof nj.uid === "string" && nj.uid !== "") node.uid = nj.uid;
2094
2990
  node.name = nj.name;
@@ -2103,6 +2999,7 @@ function applyCommon(node, nj, opts) {
2103
2999
  node.behavior = behavior;
2104
3000
  for (const s of mergeStaticSignals(behavior.constructor)) node.declareSignal(s);
2105
3001
  }
3002
+ if (loadingAssets) validateAssetRefs(nj.props ?? {}, propsFor(node), loadingAssets, node.name);
2106
3003
  if (nj.network) {
2107
3004
  validateNetworkBlock(nj.network, node.name);
2108
3005
  node.network = jsonClone(nj.network);
@@ -2125,6 +3022,7 @@ function wireConnections(root, connections, declareSignals = false) {
2125
3022
  const nodeFn = toNode[conn.handler];
2126
3023
  const behaviorFn = toNode.behavior ? toNode.behavior[conn.handler] : void 0;
2127
3024
  if (typeof nodeFn !== "function" && typeof behaviorFn !== "function" && !(toNode.behavior instanceof StubBehavior)) throw new IncantoError("UNKNOWN_HANDLER", `Connection '${conn.signal}' → '${conn.to}' names handler '${conn.handler}', but '${toNode.name}' has no such method` + (toNode.behavior ? ` (nor does its behavior '${toNode.script?.name}')` : "") + `.`);
3025
+ if (ambiguousHandler(toNode, conn.handler)) throw ambiguousHandlerError(toNode, conn.handler, toNode.script?.name, conn.to);
2128
3026
  const listener = (...args) => {
2129
3027
  if (conn.filter && !matchesFilter(args[0], conn.filter)) return;
2130
3028
  if (conn.once) fromNode.off(conn.signal, listener);
@@ -2203,4 +3101,4 @@ function validateUniqueUids(root) {
2203
3101
  walk(root);
2204
3102
  }
2205
3103
  //#endregion
2206
- export { getBehavior as A, Node as C, behaviorSchema as D, Behavior as E, registeredBehaviors as M, Signal as N, behaviorSignals as O, InputMap as S, parseNodePath as T, savesWithoutUid as _, serializeNode as a, effectiveOrder as b, resolveViewport as c, isConstRef as d, resolveConstants as f, restoreBehaviors as g, captureBehaviors as h, Scene as i, registerBehavior as j, clearBehaviors as k, SceneTree as l, behaviorsWithoutSave as m, loadScene as n, SCENE_FORMAT as o, SaveSlots as p, loader_exports as r, computeViewport as s, buildNodeJson as t, CONST_REF_KEY as u, createSaveStore as v, diagnose as w, resolveOrderGroups as x, ORDER_GROUP_BASE as y };
3104
+ export { describeRefProblem as A, registeredBehaviors as B, ORDER_GROUP_BASE as C, handlerShadowWarnings as D, InputMap as E, behaviorSchema as F, behaviorSignals as I, clearBehaviors as L, resolveRefInJson as M, parseNodePath as N, Node as O, Behavior as P, getBehavior as R, createSaveStore as S, resolveOrderGroups as T, Signal as V, SaveSlots as _, serializeNode as a, restoreBehaviors as b, parseEnvironment3D as c, computeViewport as d, resolveViewport as f, resolveConstants as g, isConstRef as h, Scene as i, nodeRefWarnings as j, diagnose as k, sunDirectionFromElevationAzimuth as l, CONST_REF_KEY as m, loadScene as n, SCENE_FORMAT as o, SceneTree as p, loader_exports as r, horizonColorFromSky as s, buildNodeJson as t, sunDirectionFromSky as u, behaviorsWithoutSave as v, effectiveOrder as w, savesWithoutUid as x, captureBehaviors as y, registerBehavior as z };