incanto 0.66.0 → 0.68.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 (96) hide show
  1. package/bin/_behaviors-loader.mjs +87 -0
  2. package/bin/_pick-server.mjs +48 -0
  3. package/bin/incanto-check.mjs +16 -2
  4. package/bin/incanto-editor.mjs +39 -2
  5. package/bin/incanto-feel.mjs +4 -1
  6. package/bin/incanto-frame.mjs +37 -3
  7. package/bin/incanto-logs.mjs +23 -3
  8. package/bin/incanto-multiplay.mjs +50 -1
  9. package/bin/incanto-play.mjs +3 -1
  10. package/bin/incanto-playtest.mjs +3 -25
  11. package/bin/incanto-verify.mjs +15 -2
  12. package/dist/2d.d.ts +44 -7
  13. package/dist/2d.js +6 -4
  14. package/dist/3d.d.ts +112 -16
  15. package/dist/3d.js +8 -6
  16. package/dist/{audio-player-_UAcHxnC.d.ts → audio-player-DOrq7sP-.d.ts} +62 -4
  17. package/dist/{behavior-BXNLfIJk.d.ts → behavior-DoFPYrgo.d.ts} +117 -2
  18. package/dist/boot-failure-CKYrEcGF.js +47 -0
  19. package/dist/{create-game-DYJCIzO0.js → create-game-DbWtVTxD.js} +91 -29
  20. package/dist/{create-game-Dd7H4bJV.js → create-game-IZIydDwI.js} +16 -10
  21. package/dist/debug.d.ts +1 -1
  22. package/dist/debug.js +3 -2
  23. package/dist/{duplicate-CqSAtdrh.js → duplicate-IWIqk0HJ.js} +1 -1
  24. package/dist/{editor-switch-DAvWQeld.d.ts → editor-switch-CnIOiyNJ.d.ts} +1 -1
  25. package/dist/editor.js +18 -7
  26. package/dist/env.d.ts +1 -1
  27. package/dist/{environment-presets-BlPsEmq6.js → environment-presets-CybQXNqS.js} +100 -11
  28. package/dist/{gameplay-D1RADWu3.js → gameplay-DM1eu_cV.js} +46 -5
  29. package/dist/gameplay.d.ts +75 -6
  30. package/dist/gameplay.js +2 -2
  31. package/dist/index.d.ts +52 -8
  32. package/dist/index.js +9 -7
  33. package/dist/{loader-lQDCwNag.js → loader-BC4PNtJX.js} +173 -1
  34. package/dist/{loader-Cga7FVP4.d.ts → loader-CcB533FR.d.ts} +2 -2
  35. package/dist/net.d.ts +2 -2
  36. package/dist/net.js +1 -1
  37. package/dist/{physics-2d-Cji5A6sX.js → physics-2d-B7Y6dPZO.js} +29 -2
  38. package/dist/{physics-3d-BkHjwJgI.js → physics-3d-bG3n70Ky.js} +30 -3
  39. package/dist/react.d.ts +2 -2
  40. package/dist/react.js +1 -1
  41. package/dist/{register-BmuqYTiY.js → register-3ta-2Xig.js} +148 -22
  42. package/dist/{register-D0CxCveZ.js → register-ibCjm-wH.js} +3 -3
  43. package/dist/{replay-IsZbNu6d.js → replay-CYvhVHHN.js} +28 -4
  44. package/dist/{replay-BHoB6fCU.d.ts → replay-Dvn8aeBd.d.ts} +38 -4
  45. package/dist/{schema-CFeioQRE.d.ts → schema-B6ugCV1Q.d.ts} +11 -0
  46. package/dist/{split-screen-D_i7GRcY.js → split-screen-CYwDkbLF.js} +2 -2
  47. package/dist/{split-screen-eJUFjhi1.d.ts → split-screen-DhrSzZIB.d.ts} +3 -3
  48. package/dist/{src-BTLbXFPZ.js → src-D7RIqXYF.js} +1 -1
  49. package/dist/{teardown-byR9USax.js → teardown-D2NEmxPB.js} +2 -2
  50. package/dist/{test-DgrD0jHD.js → test-8hoHeRmo.js} +140 -32
  51. package/dist/test.d.ts +9 -7
  52. package/dist/test.js +3 -3
  53. package/dist/{touch-DESwnpOc.js → touch-DEAmqGdf.js} +30 -186
  54. package/dist/vite.d.ts +0 -7
  55. package/dist/vite.js +13 -3
  56. package/dist/webgl-unavailable-N9nQqesw.js +228 -0
  57. package/editor/assets/{agent8-9N-Pd_YS.js → agent8-BrrHOjMJ.js} +1 -1
  58. package/editor/assets/debug-BBhKuBNV.js +3 -0
  59. package/editor/assets/{index-CeDhIPTC.js → index-eVd0BToA.js} +92 -92
  60. package/editor/index.html +1 -1
  61. package/package.json +1 -1
  62. package/schemas/scene.schema.json +5 -1
  63. package/skills/incanto-3d-character.md +1 -0
  64. package/skills/incanto-audio.md +49 -0
  65. package/skills/incanto-building-2d-games.md +37 -0
  66. package/skills/incanto-building-3d-games.md +56 -0
  67. package/skills/incanto-editor.md +5 -1
  68. package/skills/incanto-environment.md +2 -2
  69. package/skills/incanto-gameplay-behaviors.md +29 -2
  70. package/skills/incanto-hud.md +14 -1
  71. package/skills/incanto-multiplayer.md +14 -0
  72. package/skills/incanto-node-reference.md +2 -1
  73. package/skills/incanto-performance.md +35 -0
  74. package/skills/incanto-physics-and-input.md +23 -0
  75. package/skills/incanto-playtesting.md +15 -1
  76. package/skills/incanto-save-slots.md +21 -1
  77. package/skills/incanto-scene-json-authoring.md +12 -1
  78. package/skills/incanto-verifying-your-game.md +19 -0
  79. package/skills/incanto-your-first-game.md +4 -4
  80. package/templates-app/beacon-isle-3d/package.json +1 -1
  81. package/templates-app/beacon-isle-3d/src/game.scene.json +38 -19
  82. package/templates-app/beacon-isle-3d/src/main.ts +8 -1
  83. package/templates-app/platformer-2d/package.json +1 -1
  84. package/templates-app/platformer-2d/src/game.scene.json +99 -53
  85. package/templates-app/platformer-2d/src/main.ts +8 -1
  86. package/templates-app/star-survivor/package.json +1 -1
  87. package/templates-app/star-survivor/src/game.scene.json +31 -17
  88. package/templates-app/star-survivor/src/main.ts +8 -1
  89. package/templates-app/tps-3d/package.json +1 -1
  90. package/templates-app/tps-3d/src/game.scene.json +14 -8
  91. package/templates-app/tps-3d/src/main.ts +8 -1
  92. package/templates-app/village-quest-3d/package.json +1 -1
  93. package/templates-app/village-quest-3d/src/grove.scene.json +20 -11
  94. package/templates-app/village-quest-3d/src/main.ts +8 -1
  95. package/templates-app/village-quest-3d/src/village.scene.json +10 -7
  96. package/editor/assets/debug-CkbJICYp.js +0 -3
@@ -1,10 +1,10 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { k as diagnose } from "./loader-lQDCwNag.js";
2
+ import { k as diagnose } from "./loader-BC4PNtJX.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { H as CharacterBody3D, K as Node3D, U as PhysicsBody3D, V as Area3D, W as RigidBody3D, q as validateCollider3D } from "./gameplay-D1RADWu3.js";
4
+ import { H as CharacterBody3D, K as Node3D, U as PhysicsBody3D, V as Area3D, W as RigidBody3D, q as validateCollider3D } from "./gameplay-DM1eu_cV.js";
5
5
  import { n as registerDebugSource } from "./debug-draw-BM3DsvtT.js";
6
6
  import { n as jointContacts, t as withoutRapierInitNoise } from "./quiet-rapier-C6fW4zcW.js";
7
- import { A as Terrain3D, F as InstancedMesh3D, L as buildMeshGeometry, P as Joint3D } from "./environment-presets-BlPsEmq6.js";
7
+ import { A as Terrain3D, F as InstancedMesh3D, L as buildMeshGeometry, P as Joint3D } from "./environment-presets-CybQXNqS.js";
8
8
  import { Euler, Matrix4, Quaternion, Vector3 } from "three";
9
9
  //#region src/3d/physics/collider-lines.ts
10
10
  /**
@@ -262,6 +262,7 @@ var Physics3D = class {
262
262
  this.unregisterDebug = registerDebugSource(this);
263
263
  this.disconnect = engine.fixedUpdated.connect((dt) => this.step(dt));
264
264
  this.disconnectScene = engine.sceneChanged.connect(() => {
265
+ this.pruneOverlaps();
265
266
  if (engine.scene) this.syncBodies();
266
267
  });
267
268
  engine.physics = this;
@@ -609,6 +610,7 @@ var Physics3D = class {
609
610
  this.byColliderHandle.delete(e.collider.handle);
610
611
  this.world.removeRigidBody(e.body);
611
612
  this.entries.delete(node);
613
+ this.forgetOverlaps(node);
612
614
  node._physics = null;
613
615
  node._interpPrev = node._interpCurr = null;
614
616
  }
@@ -824,6 +826,31 @@ var Physics3D = class {
824
826
  }
825
827
  }
826
828
  /**
829
+ * Forget every overlap whose node has left the tree.
830
+ *
831
+ * The lazy drop inside `overlapping()` only cleans the SETS of a key someone
832
+ * asks about, and only when they ask. This clears the KEYS, which is where the
833
+ * dead nodes were pinned.
834
+ */
835
+ /** Drop one node from the overlap map, both as a key and as a member. */
836
+ forgetOverlaps(node) {
837
+ this.overlaps.delete(node);
838
+ for (const [self, others] of this.overlaps) {
839
+ others.delete(node);
840
+ if (others.size === 0) this.overlaps.delete(self);
841
+ }
842
+ }
843
+ pruneOverlaps() {
844
+ for (const [self, others] of this.overlaps) {
845
+ if (self.tree === null) {
846
+ this.overlaps.delete(self);
847
+ continue;
848
+ }
849
+ for (const other of others) if (other.tree === null) others.delete(other);
850
+ if (others.size === 0) this.overlaps.delete(self);
851
+ }
852
+ }
853
+ /**
827
854
  * The bodies currently overlapping `node` — the standing answer behind
828
855
  * `triggerEnter`/`triggerExit`.
829
856
  *
package/dist/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-BXNLfIJk.js";
2
- import { c as JsonValue } from "./schema-CFeioQRE.js";
1
+ import { P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-DoFPYrgo.js";
2
+ import { c as JsonValue } from "./schema-B6ugCV1Q.js";
3
3
  import { CSSProperties, ReactNode } from "react";
4
4
 
5
5
  //#region src/react/index.d.ts
package/dist/react.js CHANGED
@@ -156,7 +156,7 @@ function IncantoCanvas(props) {
156
156
  pointer: latest.pointer,
157
157
  ...keyboard !== void 0 ? { keyboard } : {}
158
158
  };
159
- const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-DYJCIzO0.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-Dd7H4bJV.js").then((n) => n.n)).createGame2D(o)))(opts);
159
+ const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-DbWtVTxD.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-IZIydDwI.js").then((n) => n.n)).createGame2D(o)))(opts);
160
160
  if (disposed) {
161
161
  next.dispose();
162
162
  return;
@@ -1,8 +1,9 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { C as ORDER_GROUP_BASE, E as InputMap, O as Node, S as createSaveStore, T as resolveOrderGroups, V as Signal, b as restoreBehaviors, k as diagnose, y as captureBehaviors } from "./loader-lQDCwNag.js";
2
+ import { C as ORDER_GROUP_BASE, E as InputMap, O as Node, S as createSaveStore, T as resolveOrderGroups, V as Signal, b as restoreBehaviors, k as diagnose, y as captureBehaviors } from "./loader-BC4PNtJX.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 { l as registerNode } from "./registry-CF70EArN.js";
6
+ import { r as capturePointer } from "./touch-DEAmqGdf.js";
6
7
  //#region src/core/audio/buses.ts
7
8
  const AUDIO_LOG_LIMIT = 200;
8
9
  var AudioBuses = class {
@@ -178,6 +179,11 @@ var WebAudioTrack = class {
178
179
  setLoop(loop) {
179
180
  this.el.loop = loop;
180
181
  }
182
+ /** The element's own playhead — the clock the sound is actually on. */
183
+ playhead() {
184
+ const at = this.el.currentTime;
185
+ return typeof at === "number" && Number.isFinite(at) ? at : null;
186
+ }
181
187
  play() {
182
188
  this.el.play().catch((error) => {
183
189
  const name = error?.name ?? "";
@@ -242,7 +248,8 @@ const SILENT_BACKEND = {
242
248
  setGain() {},
243
249
  setLoop() {},
244
250
  play() {},
245
- stop() {}
251
+ stop() {},
252
+ playhead: () => null
246
253
  }),
247
254
  unlock() {}
248
255
  };
@@ -282,6 +289,24 @@ var MusicManager = class {
282
289
  get current() {
283
290
  return this.active?.track.src ?? null;
284
291
  }
292
+ /**
293
+ * Seconds into the current track — the playhead of the thing you can HEAR.
294
+ *
295
+ * `null` when nothing is playing, or when the backend has no clock (headless,
296
+ * or a custom backend that does not implement it).
297
+ *
298
+ * A game charted to a soundtrack needs this and there was no way to get it:
299
+ * `current` gave the src and the backend was private, so the only option was
300
+ * an `engine.time` counter started next to `music.play()` — a different clock
301
+ * from the one the audio is on, and one that keeps counting through a
302
+ * gesture-block, a stall or a seek. This is the audio's own answer.
303
+ *
304
+ * It is the RAW element time, so a looping track wraps to 0 at each pass:
305
+ * that is a loop boundary you can chart against, not a fault.
306
+ */
307
+ get playhead() {
308
+ return this.active?.track.playhead?.() ?? null;
309
+ }
285
310
  /** Per-manager volume (0..1), on top of the bus gain. */
286
311
  get volume() {
287
312
  return this._volume;
@@ -1035,6 +1060,16 @@ var SfxEngine = class {
1035
1060
  if (ctx && ctx.state === "suspended") ctx.resume();
1036
1061
  }
1037
1062
  /**
1063
+ * The AUDIO clock, in seconds — the one a sound is actually scheduled
1064
+ * against, and the only clock that does not drift relative to what you hear.
1065
+ *
1066
+ * `0` headless and before the first sound, since there is no context yet.
1067
+ * Schedule with a lead: `sfx.play(params, 1, { when: sfx.now + 0.08 })`.
1068
+ */
1069
+ get now() {
1070
+ return this.ctx?.currentTime ?? 0;
1071
+ }
1072
+ /**
1038
1073
  * Play a preset at `gain` (0..1, already bus-multiplied by the caller). Each
1039
1074
  * call spawns its own source so rapid repeats overlap instead of cutting off.
1040
1075
  * Headless → no-op.
@@ -1076,7 +1111,7 @@ var SfxEngine = class {
1076
1111
  const source = ctx.createBufferSource();
1077
1112
  source.buffer = buffer;
1078
1113
  source.connect(gainNode);
1079
- source.start();
1114
+ source.start(opts.when === void 0 ? 0 : Math.max(0, opts.when));
1080
1115
  }
1081
1116
  };
1082
1117
  //#endregion
@@ -1339,11 +1374,29 @@ var HudLayer = class extends Node {
1339
1374
  }
1340
1375
  }
1341
1376
  }
1342
- /** Focusable widgets under this layer, in tree order. */
1377
+ /**
1378
+ * Focusable widgets under this layer, in tree order — **on screen only**.
1379
+ *
1380
+ * This filtered on each widget's OWN `visible` and recursed into hidden
1381
+ * containers anyway, so arrow-keys walked into closed panels and Enter really
1382
+ * pressed what it found. Measured on a title screen: 23 focus stops, 21 of
1383
+ * them under an ancestor with `visible = false`, and
1384
+ *
1385
+ * gold before = 150 -> Enter on the hidden Shop/Row0/Buy -> gold 120,
1386
+ * screen still 'title'
1387
+ *
1388
+ * The layer's own visibility was not a gate either. It is now: a HUD nobody
1389
+ * can see has no ring, and a subtree nobody can see is not walked.
1390
+ *
1391
+ * PUBLIC, because a game that wants to know what its ring contains could not
1392
+ * ask — and "why is focus on that" is exactly the question this answers.
1393
+ */
1343
1394
  focusables() {
1344
1395
  const out = [];
1396
+ if (!this.visible) return out;
1345
1397
  const walk = (node) => {
1346
- if (node instanceof HudWidgetBase && node.focusable && node.visible) out.push(node);
1398
+ if (node instanceof HudWidgetBase && !node.visible) return;
1399
+ if (node instanceof HudWidgetBase && node.focusable) out.push(node);
1347
1400
  for (const child of node.children) walk(child);
1348
1401
  };
1349
1402
  walk(this);
@@ -1513,7 +1566,7 @@ var HudWidgetBase = class extends Node {
1513
1566
  el.addEventListener("pointerdown", (event) => {
1514
1567
  if (!this.draggable) return;
1515
1568
  event.preventDefault();
1516
- el.setPointerCapture?.(event.pointerId);
1569
+ capturePointer(el, event.pointerId);
1517
1570
  dragging = this;
1518
1571
  draggingFrom = dropTargetFromElement(el.parentElement);
1519
1572
  el.style.opacity = "0.6";
@@ -1553,10 +1606,16 @@ var HudWidgetBase = class extends Node {
1553
1606
  this._mountWidget();
1554
1607
  if (!this._element) return;
1555
1608
  }
1556
- this._element.style.display = this.visible ? "" : "none";
1557
1609
  this._followAnchor();
1558
1610
  this._sync();
1611
+ if (this._element.style.display !== "none") this._shownDisplay = this._element.style.display;
1612
+ this._element.style.display = this.visible ? this._shownDisplay : "none";
1559
1613
  }
1614
+ /**
1615
+ * The display this widget's own css asks for while shown — `grid` for a grid
1616
+ * panel, `flex` for a row, `''` for a widget that never says.
1617
+ */
1618
+ _shownDisplay = "";
1560
1619
  /** Which anchor slot this widget was mounted into. */
1561
1620
  mountedAnchor = null;
1562
1621
  /**
@@ -2280,14 +2339,9 @@ function qualityEnvironment(tier) {
2280
2339
  };
2281
2340
  if (tier === "medium") return {
2282
2341
  shadows: { static: true },
2283
- bloom: {},
2284
2342
  post: null
2285
2343
  };
2286
- return {
2287
- shadows: true,
2288
- bloom: {},
2289
- post: {}
2290
- };
2344
+ return { shadows: true };
2291
2345
  }
2292
2346
  const QUALITY_CAPS = Object.freeze({
2293
2347
  low: Object.freeze({
@@ -2436,7 +2490,7 @@ var Engine = class {
2436
2490
  * reset on reload. Reading it here costs nothing (localStorage, once) and
2437
2491
  * `bindAudio` makes the volumes write themselves.
2438
2492
  */
2439
- settings = new Settings();
2493
+ settings;
2440
2494
  /**
2441
2495
  * Translations, and the locale in force. English is the base and the default;
2442
2496
  * anything a locale does not declare falls back to it silently.
@@ -2510,6 +2564,7 @@ var Engine = class {
2510
2564
  this.scheduler = opts.scheduler ?? rafScheduler;
2511
2565
  this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
2512
2566
  this.maxFps = opts.maxFps ?? 0;
2567
+ this.settings = opts.settings instanceof Settings ? opts.settings : new Settings(opts.settings?.namespace ?? "settings");
2513
2568
  this.sfx._setBuses(this.audio);
2514
2569
  this.music.onTrackError = (src, reason) => {
2515
2570
  diagnose(this, "error", `[incanto] music '${src}' will not play — ${reason}.`);
@@ -2636,8 +2691,9 @@ var Engine = class {
2636
2691
  captureState() {
2637
2692
  const root = this.scene?.root;
2638
2693
  if (!root) return {};
2639
- const { state, unaddressable } = captureBehaviors(root, this.scene?.source);
2694
+ const { state, unaddressable, unrecordable } = captureBehaviors(root, this.scene?.source);
2640
2695
  for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
2696
+ if (unrecordable.length > 0) diagnose(this, "error", `[incanto] save: ${unrecordable.length} authored node(s) this run consumed have no uid, so nothing records that they are gone and LOADING THIS SAVE BRINGS THEM BACK: ${unrecordable.slice(0, 8).join(", ")}${unrecordable.length > 8 ? `, +${unrecordable.length - 8} more` : ""}. Give each one a "uid" from newUid().`);
2641
2697
  return state;
2642
2698
  }
2643
2699
  /** Give it back, after the scene is loaded and onReady has run. */
@@ -2983,10 +3039,32 @@ var AudioPlayer = class extends Node {
2983
3039
  rolloff: this.rolloff
2984
3040
  };
2985
3041
  }
2986
- play() {
3042
+ /**
3043
+ * Start the sound. `at` schedules it on the AUDIO clock instead of now.
3044
+ *
3045
+ * A frame is a 16.67 ms grid at 60 Hz and 33.33 ms at 30, so a sound fired
3046
+ * from `update()` cannot land closer than one frame to where a chart wants
3047
+ * it — measured 0 of 64 notes scheduled on the documented path. Queue it a
3048
+ * lead ahead of `engine.sfx.now` and the same notes land at |mean| 0.000000
3049
+ * ms:
3050
+ *
3051
+ * ```ts
3052
+ * const lead = 0.08; // schedule this far ahead
3053
+ * for (const note of dueSoon(engine.sfx.now + lead)) {
3054
+ * hit.play(engine.sfx.now + (note.atSec - songSeconds));
3055
+ * }
3056
+ * ```
3057
+ *
3058
+ * PRESETS only — a `src` clip goes through an `<audio>` element, which has no
3059
+ * scheduling clock, and passing `at` for one is ignored rather than silently
3060
+ * approximated. Everything else is unchanged: the bus gain applies, the sound
3061
+ * lands in `engine.audio.recent()`, and `muted` still silences it, none of
3062
+ * which a hand-rolled `AudioContext` scheduler keeps.
3063
+ */
3064
+ play(at) {
2987
3065
  if (this.preset !== "custom") {
2988
3066
  this.note("preset", this.preset);
2989
- this.playPreset();
3067
+ this.playPreset(at);
2990
3068
  return;
2991
3069
  }
2992
3070
  if (!this.src) return;
@@ -3030,7 +3108,7 @@ var AudioPlayer = class extends Node {
3030
3108
  * the length is arithmetic, not a device. WebAudio needs a gesture; a
3031
3109
  * suspended context is resumed by the same unlock listener as `src` players.
3032
3110
  */
3033
- playPreset() {
3111
+ playPreset(at) {
3034
3112
  const params = SFX_PRESETS[this.preset];
3035
3113
  if (!params) return;
3036
3114
  const engine = this.tree?.engine;
@@ -3041,7 +3119,8 @@ var AudioPlayer = class extends Node {
3041
3119
  sfx.play(params, this.gain(), {
3042
3120
  pitch: this.pitch,
3043
3121
  seed: this.seed,
3044
- spatial: this.spatialPlay()
3122
+ spatial: this.spatialPlay(),
3123
+ when: at
3045
3124
  });
3046
3125
  }
3047
3126
  /**
@@ -3077,6 +3156,10 @@ var AudioPlayer = class extends Node {
3077
3156
  }
3078
3157
  if (this.presetEndsAt !== null) {
3079
3158
  if ((this.tree?.engine?.unscaledTime ?? 0) >= this.presetEndsAt) {
3159
+ if (this.loop) {
3160
+ this.playPreset();
3161
+ return;
3162
+ }
3080
3163
  this.presetEndsAt = null;
3081
3164
  this._playing = false;
3082
3165
  this.emit("finished");
@@ -3187,6 +3270,7 @@ var UiDialogue = class extends HudWidgetBase {
3187
3270
  ];
3188
3271
  static props = {
3189
3272
  ...HudWidgetBase.props,
3273
+ focusable: { default: true },
3190
3274
  anchor: {
3191
3275
  default: "bottom",
3192
3276
  options: [
@@ -3236,6 +3320,49 @@ var UiDialogue = class extends HudWidgetBase {
3236
3320
  });
3237
3321
  }
3238
3322
  /**
3323
+ * A dialogue takes focus while it is up.
3324
+ *
3325
+ * It paints a focus ring and the generated reference advertises the prop, and
3326
+ * it was inert: choices are raw DOM buttons inside the widget, so
3327
+ * `HudLayer.focusables()` can never see them, and this class — unlike
3328
+ * `UiButton` a hundred lines above it — never overrode `_activate`. Eleven
3329
+ * keys and eight pad codes did nothing to a waiting choice while the same
3330
+ * Enter pressed a sibling button. Native Tab worked, and is closed by any
3331
+ * game that binds Tab; a pad has no Tab at all.
3332
+ */
3333
+ focusable = true;
3334
+ /** Which choice the keyboard/pad is on. Reset by every new line. */
3335
+ cursor = 0;
3336
+ /**
3337
+ * Enter / A, and left / right between the options.
3338
+ *
3339
+ * `dir` is 0 for "activate" and ±1 for a nudge, the same shape `UiSelect`
3340
+ * uses — so one key layout drives a menu, a slider and a conversation.
3341
+ */
3342
+ _activate(dir) {
3343
+ if (!this.active || !this.current) return;
3344
+ const choices = this.current.choices;
3345
+ if (!choices || choices.length === 0) {
3346
+ this.advance();
3347
+ return;
3348
+ }
3349
+ if (dir !== 0) {
3350
+ this.cursor = (this.cursor + dir + choices.length) % choices.length;
3351
+ this.paintCursor();
3352
+ return;
3353
+ }
3354
+ this.choose(this.cursor);
3355
+ }
3356
+ /** Show which option the keyboard is on — a ring nobody can see is not one. */
3357
+ paintCursor() {
3358
+ const buttons = this.choicesEl?.children;
3359
+ if (!buttons) return;
3360
+ for (let i = 0; i < buttons.length; i++) {
3361
+ const el = buttons[i];
3362
+ if (el) el.style.outline = i === this.cursor ? "2px solid #fff" : "none";
3363
+ }
3364
+ }
3365
+ /**
3239
3366
  * Player pressed "next": mid-typing reveals the whole line; a fully-typed
3240
3367
  * line without choices advances (choice lines wait for `choose`).
3241
3368
  */
@@ -3318,12 +3445,11 @@ var UiDialogue = class extends HudWidgetBase {
3318
3445
  this.revealed = Math.round(progress * this.current.text.length);
3319
3446
  if (this.speakerEl) this.speakerEl.textContent = this.current.speaker;
3320
3447
  }
3321
- const wasTyping = this.revealed < this.current.text.length;
3322
- if (wasTyping) this.revealed = Math.min(this.current.text.length, this.revealed + this.charsPerSecond * dt);
3448
+ if (this.revealed < this.current.text.length) this.revealed = Math.min(this.current.text.length, this.revealed + this.charsPerSecond * dt);
3323
3449
  const doneTyping = this.revealed >= this.current.text.length;
3324
3450
  if (this.textEl) this.textEl.textContent = this.showing;
3325
3451
  if (this.hintEl) this.hintEl.style.visibility = doneTyping && !this.current.choices ? "visible" : "hidden";
3326
- if (doneTyping && wasTyping && this.current.choices && this.choicesEl) this.current.choices.forEach((label, i) => {
3452
+ if (doneTyping && this.current.choices && this.choicesEl && this.choicesEl.children.length === 0) this.current.choices.forEach((label, i) => {
3327
3453
  const b = document.createElement("button");
3328
3454
  b.type = "button";
3329
3455
  b.textContent = label;
@@ -1,10 +1,10 @@
1
- import { O as Node, k as diagnose, w as effectiveOrder } from "./loader-lQDCwNag.js";
2
- import { L as translateOn, t as registerCoreNodes } from "./register-BmuqYTiY.js";
1
+ import { O as Node, k as diagnose, w as effectiveOrder } from "./loader-BC4PNtJX.js";
2
+ import { L as translateOn, t as registerCoreNodes } from "./register-3ta-2Xig.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-CF70EArN.js";
6
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
- import { F as localFromWorld, I as worldPosition } from "./gameplay-D1RADWu3.js";
7
+ import { F as localFromWorld, I as worldPosition } from "./gameplay-DM1eu_cV.js";
8
8
  import { t as checkSheetGrid } from "./sheet-grid-BT6N_Bjs.js";
9
9
  import { AdditiveBlending, BufferAttribute, BufferGeometry, CanvasTexture, Color, DynamicDrawUsage, Group, InstancedMesh, MathUtils, Matrix4, Mesh, MeshBasicMaterial, NearestFilter, NormalBlending, Object3D, PlaneGeometry, SRGBColorSpace } from "three";
10
10
  //#region src/2d/physics/collider-validate.ts
@@ -1,5 +1,5 @@
1
- import { D as handlerShadowWarnings, j as nodeRefWarnings } from "./loader-lQDCwNag.js";
2
- import { R as translationKey, k as HudWidgetBase } from "./register-BmuqYTiY.js";
1
+ import { D as handlerShadowWarnings, j as nodeRefWarnings } from "./loader-BC4PNtJX.js";
2
+ import { R as translationKey, k as HudWidgetBase } from "./register-3ta-2Xig.js";
3
3
  import { o as getNodeType } from "./registry-CF70EArN.js";
4
4
  //#region src/core/audit.ts
5
5
  const ANIMATED_TYPES = new Set([
@@ -104,6 +104,28 @@ function transformOf(props) {
104
104
  if (scaled) return "scale";
105
105
  return null;
106
106
  }
107
+ /**
108
+ * Which files in a corpus some OTHER scene embeds with `instance`.
109
+ *
110
+ * A fragment cannot be recognised by looking at itself — `enemy.scene.json` and
111
+ * a broken first level are the same file shape. It is recognised by the project
112
+ * around it, which every tool that audits more than one scene already walks.
113
+ *
114
+ * `resolveRef(fromFile, ref)` turns a scene-relative `instance` path into the
115
+ * same spelling the corpus uses for that file, so the caller owns all path
116
+ * logic and this stays runnable in a browser.
117
+ */
118
+ function instancedScenes(corpus, resolveRef) {
119
+ const instanced = /* @__PURE__ */ new Set();
120
+ const walk = (node, from) => {
121
+ if (!node || typeof node !== "object") return;
122
+ const ref = node.instance;
123
+ if (typeof ref === "string" && ref) instanced.add(resolveRef(from, ref));
124
+ for (const child of node.children ?? []) walk(child, from);
125
+ };
126
+ for (const { path, json } of corpus) walk(json.root, path);
127
+ return instanced;
128
+ }
107
129
  function auditScene(scene, opts = {}) {
108
130
  const warnings = [];
109
131
  const root = scene.root;
@@ -166,7 +188,9 @@ function auditScene(scene, opts = {}) {
166
188
  if (cameraDrivers.length > 0 && followedCameras.length > 0) warnings.push(`${followedCameras.join(", ")}: a FollowCamera writes this camera's position every frame, and so does the CharacterController3D at ${cameraDrivers.join(", ")} — the controller owns the camera in every view it has. Drop one of them: use the controller's own \`view\`/\`camDistance\`, or take the controller off the camera duty.`);
167
189
  if (cameras > 0 && currentCameras === 0) warnings.push("no camera has \"current\": true — the screen renders from a default view, probably not what you framed.");
168
190
  if (currentCameras > 1) warnings.push(`${currentCameras} cameras claim "current": true — only the first found wins.`);
169
- 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\": \"...\" }.");
191
+ const isFragment = opts.instanced === true || scene.fragment === true;
192
+ if (scene.dimension === "3d" && !isFragment && cameras === 0) warnings.push("this 3D scene has no camera — the screen renders from a default view, which is almost never the shot you want. Add a Camera3D with \"current\": true, or set \"fragment\": true in the scene header if this file is a prefab another scene drops in.");
193
+ if (scene.dimension === "3d" && !isFragment && 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\": \"...\" } — or set \"fragment\": true if another scene lights this one.");
170
194
  if (damageGroups.length > 0) {
171
195
  const hurtable = hurtableGroups(root);
172
196
  for (const { path, group } of damageGroups) {
@@ -358,4 +382,4 @@ function replay(engine, recording, opts) {
358
382
  }
359
383
  }
360
384
  //#endregion
361
- export { startRecording as n, auditScene as r, replay as t };
385
+ export { instancedScenes as i, startRecording as n, auditScene as r, replay as t };
@@ -1,5 +1,5 @@
1
- import { b as Engine } from "./behavior-BXNLfIJk.js";
2
- import { s as JsonObject } from "./schema-CFeioQRE.js";
1
+ import { b as Engine } from "./behavior-DoFPYrgo.js";
2
+ import { s as JsonObject } from "./schema-B6ugCV1Q.js";
3
3
 
4
4
  //#region src/core/audit.d.ts
5
5
  /** Human-readable warnings (empty = clean). Pure JSON walk, no registry. */
@@ -15,7 +15,41 @@ interface AuditOptions {
15
15
  * has one.
16
16
  */
17
17
  declaredElsewhere?: Record<string, Record<string, string>>;
18
+ /**
19
+ * Another scene in this project embeds this file with `instance`.
20
+ *
21
+ * That, and only that, makes a scene a FRAGMENT — a thing lit and framed by
22
+ * whatever drops it in, which must not be told it will render black.
23
+ *
24
+ * The discriminator used to be "it has no camera", which is the same shape a
25
+ * beginner's FIRST 3D scene has. Measured on a ground plane and a capsule
26
+ * with no camera and no light — a guaranteed black screen:
27
+ *
28
+ * $ incanto-check
29
+ * ok src/game.scene.json
30
+ * 1/1 scene(s) valid
31
+ *
32
+ * Not the lighting warning, and not the camera one either. The checker walks
33
+ * the whole project already (it unions `strings` and `assets` the same way),
34
+ * so it can answer this by looking instead of guessing.
35
+ */
36
+ instanced?: boolean;
18
37
  }
38
+ /**
39
+ * Which files in a corpus some OTHER scene embeds with `instance`.
40
+ *
41
+ * A fragment cannot be recognised by looking at itself — `enemy.scene.json` and
42
+ * a broken first level are the same file shape. It is recognised by the project
43
+ * around it, which every tool that audits more than one scene already walks.
44
+ *
45
+ * `resolveRef(fromFile, ref)` turns a scene-relative `instance` path into the
46
+ * same spelling the corpus uses for that file, so the caller owns all path
47
+ * logic and this stays runnable in a browser.
48
+ */
49
+ declare function instancedScenes(corpus: {
50
+ path: string;
51
+ json: JsonObject;
52
+ }[], resolveRef: (fromFile: string, ref: string) => string): Set<string>;
19
53
  declare function auditScene(scene: JsonObject, opts?: AuditOptions): string[];
20
54
  //#endregion
21
55
  //#region src/core/errors.d.ts
@@ -25,7 +59,7 @@ declare function auditScene(scene: JsonObject, opts?: AuditOptions): string[];
25
59
  * Incanto fails loudly with machine-readable codes and messages that list valid
26
60
  * alternatives — AI agents self-correct on hard failures, not warnings.
27
61
  */
28
- type IncantoErrorCode = "AMBIGUOUS_HANDLER" | "BAD_FORMAT" | "BAD_NODE_PATH" | "DANGLING_CONNECTION" | "DUPLICATE_BEHAVIOR" | "DUPLICATE_NODE_TYPE" | "DUPLICATE_UNIQUE_NAME" | "DUPLICATE_UID" | "NODE_NOT_FOUND" | "PROP_TYPE_MISMATCH" | "TREE_VIOLATION" | "UNKNOWN_ANIMATION" | "UNKNOWN_ASSET" | "UNKNOWN_CONSTANT" | "UNKNOWN_BEHAVIOR" | "UNKNOWN_HANDLER" | "UNKNOWN_NODE_TYPE" | "UNKNOWN_PROP" | "UNKNOWN_SIGNAL" | "UNRESOLVED_INSTANCE";
62
+ type IncantoErrorCode = "AMBIGUOUS_HANDLER" | "BAD_FORMAT" | "BAD_NODE_PATH" | "DANGLING_CONNECTION" | "DUPLICATE_BEHAVIOR" | "DUPLICATE_NODE_TYPE" | "DUPLICATE_UNIQUE_NAME" | "DUPLICATE_UID" | "NODE_NOT_FOUND" | "PROP_TYPE_MISMATCH" | "TREE_VIOLATION" | "UNKNOWN_ANIMATION" | "UNKNOWN_ASSET" | "UNKNOWN_CONSTANT" | "UNKNOWN_BEHAVIOR" | "UNKNOWN_HANDLER" | "UNKNOWN_NODE_TYPE" | "UNKNOWN_PROP" | "UNKNOWN_SIGNAL" | "UNRESOLVED_INSTANCE" | "WEBGL_UNAVAILABLE";
29
63
  /**
30
64
  * Structured context mirrored from the prose message — tools and agents parse
31
65
  * THESE instead of regexing the message (which is for humans and may reword).
@@ -100,4 +134,4 @@ declare function replay(engine: Engine, recording: ReplayJson, opts?: {
100
134
  onTick?: (index: number) => void;
101
135
  }): void;
102
136
  //#endregion
103
- export { startRecording as a, IncantoErrorDetails as c, replay as i, auditScene as l, ReplayEvent as n, IncantoError as o, ReplayJson as r, IncantoErrorCode as s, Recorder as t };
137
+ export { startRecording as a, IncantoErrorDetails as c, replay as i, auditScene as l, ReplayEvent as n, IncantoError as o, ReplayJson as r, IncantoErrorCode as s, Recorder as t, instancedScenes as u };
@@ -74,6 +74,17 @@ interface SceneJson {
74
74
  input?: JsonObject;
75
75
  /** Physics config — `{gravity: [x,y]}` (2D px/s²) or `{gravity: [x,y,z]}` (3D m/s²). */
76
76
  physics?: JsonObject;
77
+ /**
78
+ * This scene is a PIECE of another one — a spawner's prefab, an instanced
79
+ * sub-scene — and is never rendered on its own.
80
+ *
81
+ * It exists because no tool can tell a prefab from a broken first level by
82
+ * looking at the file: both are a small tree with no camera and no light. The
83
+ * audit used to guess ("no camera means fragment") and so went silent on the
84
+ * commonest beginner mistake there is. One key, written by the author who
85
+ * knows, is the honest version. It changes nothing at runtime.
86
+ */
87
+ fragment?: boolean;
77
88
  /** Multiplayer config (interpreted in M6; preserved verbatim until then). */
78
89
  multiplayer?: JsonObject;
79
90
  /** Scene-level rendering environment (ambient light, background — interpreted by renderers). */
@@ -1,5 +1,5 @@
1
- import { O as Node, V as Signal, k as diagnose, n as loadScene, t as buildNodeJson } from "./loader-lQDCwNag.js";
2
- import { t as registerCoreNodes, v as Engine } from "./register-BmuqYTiY.js";
1
+ import { O as Node, V as Signal, k as diagnose, n as loadScene, t as buildNodeJson } from "./loader-BC4PNtJX.js";
2
+ import { t as registerCoreNodes, v as Engine } from "./register-3ta-2Xig.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { n as jsonEquals, t as jsonClone } from "./json-CwwhxQgb.js";
5
5
  import { l as registerNode } from "./registry-CF70EArN.js";
@@ -1,6 +1,6 @@
1
- import { Lt as Signal, b as Engine, jt as Node } from "./behavior-BXNLfIJk.js";
2
- import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
3
- import { t as LoadSceneOptions } from "./loader-Cga7FVP4.js";
1
+ import { Lt as Signal, b as Engine, jt as Node } from "./behavior-DoFPYrgo.js";
2
+ import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-B6ugCV1Q.js";
3
+ import { t as LoadSceneOptions } from "./loader-CcB533FR.js";
4
4
 
5
5
  //#region src/net/types.d.ts
6
6
  type Unsubscribe = () => void;
@@ -214,6 +214,6 @@ function newUid() {
214
214
  //#endregion
215
215
  //#region src/index.ts
216
216
  /** Engine version. Kept in sync with package.json by the release pipeline. */
217
- const VERSION = "0.66.0";
217
+ const VERSION = "0.68.0";
218
218
  //#endregion
219
219
  export { preloadUrls as a, preloadSceneAssets as i, newUid as n, findPath as o, assetUrls as r, gridFromRows as s, VERSION as t };
@@ -1,5 +1,5 @@
1
- import { _ as AudioPlayer } from "./register-BmuqYTiY.js";
2
- import { a as logReport, s as parseDrive } from "./touch-DESwnpOc.js";
1
+ import { _ as AudioPlayer } from "./register-3ta-2Xig.js";
2
+ import { i as logReport, o as parseDrive } from "./webgl-unavailable-N9nQqesw.js";
3
3
  import { a as frameSignature, i as frameImage, o as frameStats } from "./frame-report-BSMny7oe.js";
4
4
  //#region src/core/audio-errors.ts
5
5
  /** Every AudioPlayer under `root` whose source could not be played. */