incanto 0.67.0 → 0.69.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 (87) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/_pick-server.mjs +48 -0
  3. package/bin/incanto-assets.mjs +14 -1
  4. package/bin/incanto-check.mjs +69 -5
  5. package/bin/incanto-frame.mjs +10 -25
  6. package/bin/incanto-logs.mjs +8 -18
  7. package/bin/incanto-new.mjs +29 -1
  8. package/bin/incanto-serve.mjs +160 -0
  9. package/bin/incanto-verify.mjs +40 -0
  10. package/bin/incanto.mjs +1 -0
  11. package/dist/2d.d.ts +10 -8
  12. package/dist/2d.js +6 -4
  13. package/dist/3d.d.ts +31 -7
  14. package/dist/3d.js +9 -7
  15. package/dist/{audio-player-BNUHo0JF.d.ts → audio-player-C0j2k-yB.d.ts} +80 -3
  16. package/dist/{behavior-ZnfzKDo3.d.ts → behavior-rm8vVzu_.d.ts} +145 -1
  17. package/dist/boot-failure-CKYrEcGF.js +47 -0
  18. package/dist/{create-game-FAXdHta3.js → create-game-CvJ1wSRP.js} +27 -27
  19. package/dist/{create-game-DMetd3vR.js → create-game-CwxEDlCm.js} +15 -11
  20. package/dist/debug.d.ts +1 -1
  21. package/dist/{duplicate-B8QnX2-r.js → duplicate-Dj4L-5AA.js} +1 -1
  22. package/dist/{editor-switch-DAvWQeld.d.ts → editor-switch-CnIOiyNJ.d.ts} +1 -1
  23. package/dist/editor.js +12 -6
  24. package/dist/env.d.ts +1 -1
  25. package/dist/{environment-presets-C-5g2pMh.js → environment-presets-BO-7XoFV.js} +58 -9
  26. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  27. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  28. package/dist/{gameplay-CGSv-9XR.js → gameplay-D1KixWK8.js} +175 -151
  29. package/dist/gameplay.d.ts +51 -3
  30. package/dist/gameplay.js +2 -2
  31. package/dist/index.d.ts +6 -6
  32. package/dist/index.js +8 -7
  33. package/dist/{loader-KZESOZmE.js → loader-DZE7B07H.js} +48 -7
  34. package/dist/{loader-DyT30ArB.d.ts → loader-DadV2QCT.d.ts} +2 -2
  35. package/dist/net.d.ts +2 -2
  36. package/dist/net.js +1 -1
  37. package/dist/{physics-2d-_B6zj3zU.js → physics-2d-CF5yyjbj.js} +2 -2
  38. package/dist/{physics-3d-i1IeAY0F.js → physics-3d-DbYaEcZy.js} +3 -3
  39. package/dist/react.d.ts +2 -2
  40. package/dist/react.js +1 -1
  41. package/dist/{register-vIxU3Xbv.js → register-DbVArsns.js} +19 -10
  42. package/dist/{register-Cs53CY7s.js → register-mmfugpaQ.js} +159 -6
  43. package/dist/{replay-BkpDV51O.js → replay-BEHLDcQV.js} +28 -5
  44. package/dist/{replay-D0GcEHYU.d.ts → replay-BQfkX4x7.d.ts} +38 -4
  45. package/dist/{schema-CFeioQRE.d.ts → schema-B6ugCV1Q.d.ts} +11 -0
  46. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  47. package/dist/{split-screen-DS8RaUV_.js → split-screen-CauMwu6y.js} +2 -2
  48. package/dist/{split-screen-DD5eYIgW.d.ts → split-screen-Cg76MCV5.d.ts} +3 -3
  49. package/dist/{src-BLlsGJW9.js → src-CYEu-_S9.js} +1 -1
  50. package/dist/{teardown-LB9cTnXJ.js → teardown-CMqFVxO9.js} +5 -4
  51. package/dist/{test-81uZ967a.js → test-D6ISmuFE.js} +73 -25
  52. package/dist/test.d.ts +17 -6
  53. package/dist/test.js +3 -3
  54. package/dist/vite.d.ts +70 -2
  55. package/dist/vite.js +176 -14
  56. package/dist/{rendering-options-ByAdi8PF.js → webgl-unavailable-Z5_g8x9H.js} +57 -2
  57. package/editor/assets/{agent8-C2jtNKbx.js → agent8-BDvaZMQJ.js} +1 -1
  58. package/editor/assets/{debug-DoU3KeqS.js → debug-CMULinBW.js} +1 -1
  59. package/editor/assets/{index-CNqHojcC.js → index-CZIRr5EL.js} +92 -92
  60. package/editor/index.html +1 -1
  61. package/package.json +4 -3
  62. package/schemas/scene.schema.json +4 -0
  63. package/skills/incanto-3d-character.md +1 -0
  64. package/skills/incanto-assets.md +16 -2
  65. package/skills/incanto-audio.md +65 -0
  66. package/skills/incanto-building-2d-games.md +66 -0
  67. package/skills/incanto-building-3d-games.md +97 -2
  68. package/skills/incanto-gameplay-behaviors.md +46 -2
  69. package/skills/incanto-node-reference.md +1 -0
  70. package/skills/incanto-physics-and-input.md +23 -0
  71. package/skills/incanto-save-slots.md +27 -0
  72. package/skills/incanto-scene-json-authoring.md +12 -1
  73. package/skills/incanto-verifying-your-game.md +96 -2
  74. package/skills/incanto-your-first-game.md +2 -1
  75. package/templates-app/beacon-isle-3d/package.json +1 -1
  76. package/templates-app/beacon-isle-3d/src/main.ts +21 -4
  77. package/templates-app/platformer-2d/index.html +0 -23
  78. package/templates-app/platformer-2d/package.json +1 -1
  79. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  80. package/templates-app/platformer-2d/src/game.scene.json +17 -27
  81. package/templates-app/platformer-2d/src/main.ts +32 -13
  82. package/templates-app/star-survivor/package.json +1 -1
  83. package/templates-app/star-survivor/src/main.ts +25 -7
  84. package/templates-app/tps-3d/package.json +1 -1
  85. package/templates-app/tps-3d/src/main.ts +34 -16
  86. package/templates-app/village-quest-3d/package.json +1 -1
  87. package/templates-app/village-quest-3d/src/main.ts +21 -4
@@ -1087,6 +1087,10 @@ var InputMap = class {
1087
1087
  injectedDown = /* @__PURE__ */ new Set();
1088
1088
  injectedPressed = /* @__PURE__ */ new Set();
1089
1089
  injectedReleased = /* @__PURE__ */ new Set();
1090
+ injectedPressedNext = /* @__PURE__ */ new Set();
1091
+ injectedReleasedNext = /* @__PURE__ */ new Set();
1092
+ /** True between the start of a frame's update pass and `endFrame()`. */
1093
+ inFrame = false;
1090
1094
  injectedVectors = /* @__PURE__ */ new Map();
1091
1095
  /**
1092
1096
  * Press a button ACTION directly — no key codes involved. This is how
@@ -1112,13 +1116,13 @@ var InputMap = class {
1112
1116
  this.button(action);
1113
1117
  if (!this.injectedDown.has(action)) {
1114
1118
  this.injectedDown.add(action);
1115
- this.injectedPressed.add(action);
1119
+ (this.inFrame ? this.injectedPressedNext : this.injectedPressed).add(action);
1116
1120
  }
1117
1121
  }
1118
1122
  /** Release an injected button action (yields one justReleased frame). */
1119
1123
  releaseAction(action) {
1120
1124
  this.button(action);
1121
- if (this.injectedDown.delete(action)) this.injectedReleased.add(action);
1125
+ if (this.injectedDown.delete(action)) (this.inFrame ? this.injectedReleasedNext : this.injectedReleased).add(action);
1122
1126
  }
1123
1127
  /**
1124
1128
  * Feed an analog direction for a vector2 ACTION — virtual joysticks and
@@ -1152,6 +1156,8 @@ var InputMap = class {
1152
1156
  this.injectedDown.clear();
1153
1157
  this.injectedPressed.clear();
1154
1158
  this.injectedReleased.clear();
1159
+ this.injectedPressedNext.clear();
1160
+ this.injectedReleasedNext.clear();
1155
1161
  this.injectedVectors.clear();
1156
1162
  }
1157
1163
  dx = 0;
@@ -1448,6 +1454,16 @@ var InputMap = class {
1448
1454
  this.releasedEdge.clear();
1449
1455
  this.injectedPressed.clear();
1450
1456
  this.injectedReleased.clear();
1457
+ for (const a of this.injectedPressedNext) this.injectedPressed.add(a);
1458
+ for (const a of this.injectedReleasedNext) this.injectedReleased.add(a);
1459
+ this.injectedPressedNext.clear();
1460
+ this.injectedReleasedNext.clear();
1461
+ this.inFrame = false;
1462
+ }
1463
+ /** @internal The engine marks the update phase, so an edge injected inside
1464
+ * it can be held for the next frame. */
1465
+ beginFrame() {
1466
+ this.inFrame = true;
1451
1467
  }
1452
1468
  /**
1453
1469
  * Drop all action declarations and injected action state (physical key
@@ -1570,10 +1586,16 @@ function effectiveOrder(group, renderOrder, table = ORDER_GROUP_BASE) {
1570
1586
  //#endregion
1571
1587
  //#region src/core/save.ts
1572
1588
  const memoryStores = /* @__PURE__ */ new Map();
1589
+ /** Namespaces already warned about, so a per-frame save cannot spam the console. */
1590
+ const warnedVolatile = /* @__PURE__ */ new Set();
1573
1591
  function createSaveStore(namespace) {
1574
1592
  const prefix = `incanto:${namespace}:`;
1575
1593
  const local = storageOrNull();
1576
1594
  if (!local) {
1595
+ if (typeof localStorage !== "undefined" && !warnedVolatile.has(namespace)) {
1596
+ warnedVolatile.add(namespace);
1597
+ console.warn(`[incanto] this browser refused localStorage, so saves for '${namespace}' live in memory and are lost when the page closes (private window, storage disabled, or quota exhausted). Check \`store.persistent\` and tell the player.`);
1598
+ }
1577
1599
  let mem = memoryStores.get(namespace);
1578
1600
  if (!mem) {
1579
1601
  mem = /* @__PURE__ */ new Map();
@@ -1581,6 +1603,7 @@ function createSaveStore(namespace) {
1581
1603
  }
1582
1604
  const backing = mem;
1583
1605
  return {
1606
+ persistent: false,
1584
1607
  get: (key, fallback) => parse(backing.get(key), fallback),
1585
1608
  set: (key, value) => void backing.set(key, JSON.stringify(value)),
1586
1609
  remove: (key) => void backing.delete(key),
@@ -1588,6 +1611,7 @@ function createSaveStore(namespace) {
1588
1611
  };
1589
1612
  }
1590
1613
  return {
1614
+ persistent: true,
1591
1615
  get: (key, fallback) => parse(local.getItem(prefix + key) ?? void 0, fallback),
1592
1616
  set: (key, value) => local.setItem(prefix + key, JSON.stringify(value)),
1593
1617
  remove: (key) => local.removeItem(prefix + key),
@@ -1834,6 +1858,17 @@ var SaveSlots = class {
1834
1858
  constructor(namespace = "saves", store) {
1835
1859
  this.store = store ?? createSaveStore(namespace);
1836
1860
  }
1861
+ /**
1862
+ * Will these slots survive the page closing?
1863
+ *
1864
+ * `false` in a private window, with storage disabled, or headless — every
1865
+ * write still succeeds and every read still answers, for as long as the tab
1866
+ * lives. A load menu should say so instead of offering a slot that will not
1867
+ * be there tomorrow.
1868
+ */
1869
+ get persistent() {
1870
+ return this.store.persistent !== false;
1871
+ }
1837
1872
  /** Slot ids that have a save, newest first. */
1838
1873
  list() {
1839
1874
  const rows = this.store.get(INDEX_KEY, []).map((id) => ({
@@ -2850,6 +2885,8 @@ var Scene = class {
2850
2885
  orderGroups;
2851
2886
  /** Physics config (gravity etc. — interpreted by the physics modules). */
2852
2887
  physics;
2888
+ /** Declared piece of another scene — see `SceneJson.fragment`. */
2889
+ fragment;
2853
2890
  /** Multiplayer config (room etc. — interpreted by incanto/net). */
2854
2891
  multiplayer;
2855
2892
  /** Design-resolution viewport (consumed by renderers via resolveViewport). */
@@ -2869,6 +2906,7 @@ var Scene = class {
2869
2906
  this.orderGroups = source.orderGroups ? { ...source.orderGroups } : void 0;
2870
2907
  this.strings = source.strings ? jsonClone(source.strings) : void 0;
2871
2908
  this.physics = source.physics ? jsonClone(source.physics) : void 0;
2909
+ this.fragment = source.fragment === true ? true : void 0;
2872
2910
  this.multiplayer = source.multiplayer ? jsonClone(source.multiplayer) : void 0;
2873
2911
  this.environment = source.environment ? jsonClone(source.environment) : void 0;
2874
2912
  this.viewport = source.viewport ? jsonClone(source.viewport) : void 0;
@@ -2887,6 +2925,7 @@ var Scene = class {
2887
2925
  ...this.orderGroups ? { orderGroups: { ...this.orderGroups } } : {},
2888
2926
  ...this.strings ? { strings: jsonClone(this.strings) } : {},
2889
2927
  ...this.physics ? { physics: jsonClone(this.physics) } : {},
2928
+ ...this.fragment ? { fragment: true } : {},
2890
2929
  ...this.multiplayer ? { multiplayer: jsonClone(this.multiplayer) } : {},
2891
2930
  ...this.environment ? { environment: jsonClone(this.environment) } : {},
2892
2931
  ...this.viewport ? { viewport: jsonClone(this.viewport) } : {},
@@ -3063,6 +3102,7 @@ function validateHeader(json) {
3063
3102
  if (json.format !== 1) throw new IncantoError("BAD_FORMAT", `Unsupported scene format ${JSON.stringify(json.format)}; this engine reads format 1.`);
3064
3103
  if (json.type !== "scene") throw new IncantoError("BAD_FORMAT", `Expected "type": "scene", got ${JSON.stringify(json.type)}.`);
3065
3104
  if (typeof json.name !== "string" || json.name === "") throw new IncantoError("BAD_FORMAT", "Scene \"name\" must be a non-empty string.");
3105
+ if (json.fragment !== void 0 && typeof json.fragment !== "boolean") throw new IncantoError("BAD_FORMAT", `Scene "fragment" must be true or false, got ${JSON.stringify(json.fragment)}.`);
3066
3106
  if (json.dimension !== void 0 && json.dimension !== "2d" && json.dimension !== "3d") throw new IncantoError("BAD_FORMAT", `Scene "dimension" must be "2d" or "3d", got ${JSON.stringify(json.dimension)}.`);
3067
3107
  if (typeof json.root !== "object" || json.root === null) throw new IncantoError("BAD_FORMAT", "Scene \"root\" must be a node object.");
3068
3108
  resolveViewport(json.viewport);
@@ -3154,10 +3194,11 @@ const SCENE_KEYS = [
3154
3194
  "environment",
3155
3195
  "strings",
3156
3196
  "physics",
3197
+ "fragment",
3157
3198
  "root",
3158
3199
  "connections"
3159
3200
  ];
3160
- /** Closest known key by edit distance — only a genuinely близкая one. */
3201
+ /** Closest known key by edit distance — only a genuinely near one. */
3161
3202
  function nearestKey(key, known) {
3162
3203
  const lower = key.toLowerCase();
3163
3204
  let best = null;
@@ -3196,7 +3237,7 @@ function buildNode(nj, opts, stack, parentPath, constants, bands = []) {
3196
3237
  if (nj.instance !== void 0) return buildInstance(nj, opts, stack, path, constants, bands);
3197
3238
  if (nj.type === void 0) throw new IncantoError("BAD_FORMAT", `Node '${nj.name}' must declare either "type" or "instance".`);
3198
3239
  const node = createNode(nj.type, resolveConstants(nj.props, constants), { orderGroup: bands });
3199
- applyCommon(node, nj, opts);
3240
+ applyCommon(node, nj, opts, constants);
3200
3241
  node.constructor.validateJson?.(node);
3201
3242
  for (const childJson of nj.children ?? []) node.addChild(buildNode(childJson, opts, stack, path, constants, bands));
3202
3243
  return node;
@@ -3222,7 +3263,7 @@ function buildInstance(nj, opts, stack, path, constants, bands) {
3222
3263
  ...nj,
3223
3264
  type: void 0,
3224
3265
  props: void 0
3225
- }, opts);
3266
+ }, opts, constants);
3226
3267
  for (const childJson of nj.children ?? []) node.addChild(buildNode(childJson, opts, stack, path, constants, bands));
3227
3268
  wireConnections(node, sub.connections ?? [], opts?.declareConnectionSignals);
3228
3269
  return node;
@@ -3235,7 +3276,7 @@ function parentOf(path) {
3235
3276
  function propsFor(node) {
3236
3277
  return mergeStaticProps(node.constructor);
3237
3278
  }
3238
- function applyCommon(node, nj, opts) {
3279
+ function applyCommon(node, nj, opts, constants) {
3239
3280
  if (typeof nj.uid === "string" && nj.uid !== "") node.uid = nj.uid;
3240
3281
  node.name = nj.name;
3241
3282
  for (const g of nj.groups ?? []) node.addToGroup(g);
@@ -3244,7 +3285,7 @@ function applyCommon(node, nj, opts) {
3244
3285
  node.script = jsonClone(nj.script);
3245
3286
  const name = nj.script.name;
3246
3287
  if (typeof name !== "string" || name === "") throw new IncantoError("BAD_FORMAT", `Node '${nj.name}': "script" needs a non-empty "name" (a registered behavior).`);
3247
- const behavior = opts?.stubAllBehaviors || opts?.stubMissingBehaviors && !hasBehavior(name) ? new class extends StubBehavior {}() : createBehavior(name, nj.script.props);
3288
+ const behavior = opts?.stubAllBehaviors || opts?.stubMissingBehaviors && !hasBehavior(name) ? new class extends StubBehavior {}() : createBehavior(name, resolveConstants(nj.script.props, constants));
3248
3289
  behavior.node = node;
3249
3290
  node.behavior = behavior;
3250
3291
  for (const s of mergeStaticSignals(behavior.constructor)) node.declareSignal(s);
@@ -1,5 +1,5 @@
1
- import { P as Scene, b as Engine } from "./behavior-ZnfzKDo3.js";
2
- import { i as SceneJson } from "./schema-CFeioQRE.js";
1
+ import { P as Scene, b as Engine } from "./behavior-rm8vVzu_.js";
2
+ import { i as SceneJson } from "./schema-B6ugCV1Q.js";
3
3
 
4
4
  //#region src/core/scene/loader.d.ts
5
5
  interface LoadSceneOptions {
package/dist/net.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { d as PropSchema, jt as Node } from "./behavior-ZnfzKDo3.js";
2
- import { a as NetworkManagerOptions, c as LocalGameServer, d as ServerClass, f as createLocalGameServer, g as Unsubscribe, h as NetworkTransport, i as NetworkManager, l as LocalGameServerOptions, m as LoopbackTransport, n as SplitScreenPlayer, o as applySyncPatch, p as LoopbackHub, r as createSplitScreen, s as CollectionOptions, t as SplitScreenOptions, u as LocalGameServerTransport } from "./split-screen-DD5eYIgW.js";
1
+ import { d as PropSchema, jt as Node } from "./behavior-rm8vVzu_.js";
2
+ import { a as NetworkManagerOptions, c as LocalGameServer, d as ServerClass, f as createLocalGameServer, g as Unsubscribe, h as NetworkTransport, i as NetworkManager, l as LocalGameServerOptions, m as LoopbackTransport, n as SplitScreenPlayer, o as applySyncPatch, p as LoopbackHub, r as createSplitScreen, s as CollectionOptions, t as SplitScreenOptions, u as LocalGameServerTransport } from "./split-screen-Cg76MCV5.js";
3
3
 
4
4
  //#region src/net/agent8.d.ts
5
5
  /**
package/dist/net.js CHANGED
@@ -1,3 +1,3 @@
1
1
  import { n as createAgent8Server } from "./agent8-CvsfVskX.js";
2
- import { a as NetworkManager, d as createLocalGameServer, f as LoopbackHub, l as LocalGameServer, n as registerNodesNet, o as applySyncPatch, p as LoopbackTransport, r as NetworkSpawner, t as createSplitScreen, u as LocalGameServerTransport } from "./split-screen-DS8RaUV_.js";
2
+ import { a as NetworkManager, d as createLocalGameServer, f as LoopbackHub, l as LocalGameServer, n as registerNodesNet, o as applySyncPatch, p as LoopbackTransport, r as NetworkSpawner, t as createSplitScreen, u as LocalGameServerTransport } from "./split-screen-CauMwu6y.js";
3
3
  export { LocalGameServer, LocalGameServerTransport, LoopbackHub, LoopbackTransport, NetworkManager, NetworkSpawner, applySyncPatch, createAgent8Server, createLocalGameServer, createSplitScreen, registerNodesNet };
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { k as diagnose } from "./loader-KZESOZmE.js";
2
+ import { k as diagnose } from "./loader-DZE7B07H.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { _ as RigidBody2D, b as validateCollider2D, g as PhysicsBody2D, h as CharacterBody2D, m as Area2D, p as Joint2D, y as Node2D } from "./register-vIxU3Xbv.js";
4
+ import { _ as RigidBody2D, b as validateCollider2D, g as PhysicsBody2D, h as CharacterBody2D, m as Area2D, p as Joint2D, y as Node2D } from "./register-DbVArsns.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
7
  //#region src/2d/physics/physics-2d.ts
@@ -1,10 +1,10 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
- import { k as diagnose } from "./loader-KZESOZmE.js";
2
+ import { k as diagnose } from "./loader-DZE7B07H.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-CGSv-9XR.js";
4
+ import { H as CharacterBody3D, K as Node3D, U as PhysicsBody3D, V as Area3D, W as RigidBody3D, q as validateCollider3D } from "./gameplay-D1KixWK8.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-C-5g2pMh.js";
7
+ import { A as Terrain3D, F as InstancedMesh3D, L as buildMeshGeometry, P as Joint3D } from "./environment-presets-BO-7XoFV.js";
8
8
  import { Euler, Matrix4, Quaternion, Vector3 } from "three";
9
9
  //#region src/3d/physics/collider-lines.ts
10
10
  /**
package/dist/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-ZnfzKDo3.js";
2
- import { c as JsonValue } from "./schema-CFeioQRE.js";
1
+ import { P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-rm8vVzu_.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-FAXdHta3.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-DMetd3vR.js").then((n) => n.n)).createGame2D(o)))(opts);
159
+ const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-CvJ1wSRP.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-CwxEDlCm.js").then((n) => n.n)).createGame2D(o)))(opts);
160
160
  if (disposed) {
161
161
  next.dispose();
162
162
  return;
@@ -1,11 +1,11 @@
1
- import { O as Node, k as diagnose, w as effectiveOrder } from "./loader-KZESOZmE.js";
2
- import { L as translateOn, t as registerCoreNodes } from "./register-Cs53CY7s.js";
1
+ import { O as Node, k as diagnose, w as effectiveOrder } from "./loader-DZE7B07H.js";
2
+ import { L as translateOn, t as registerCoreNodes } from "./register-mmfugpaQ.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-CGSv-9XR.js";
8
- import { t as checkSheetGrid } from "./sheet-grid-BT6N_Bjs.js";
7
+ import { F as localFromWorld, I as worldPosition } from "./gameplay-D1KixWK8.js";
8
+ import { t as checkSheetGrid } from "./sheet-grid-Cea343VO.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
11
11
  /**
@@ -1443,9 +1443,11 @@ var TileMap2D = class extends Node2D {
1443
1443
  _solid = [];
1444
1444
  /** Rebuild flags — raised when the map-shaping props are REPLACED. */
1445
1445
  geometryDirty = true;
1446
- /** Reported once per atlas, so a bad grid says so on the first build only. */
1446
+ /** `texture|highestTile` already reported, so a bad grid says so once. */
1447
1447
  gridChecked = "";
1448
1448
  collidersDirty = true;
1449
+ /** The `scale` the colliders were last built for — see `rebuildColliders`. */
1450
+ builtScale = [1, 1];
1449
1451
  /** Replace the whole array to change the map (mutations are not watched). */
1450
1452
  get cells() {
1451
1453
  return this._cells;
@@ -1487,19 +1489,23 @@ var TileMap2D = class extends Node2D {
1487
1489
  this.rebuildColliders();
1488
1490
  }
1489
1491
  update(_dt) {
1492
+ const [sx = 1, sy = 1] = this.scale;
1493
+ if (sx !== this.builtScale[0] || sy !== this.builtScale[1]) this.collidersDirty = true;
1490
1494
  if (this.collidersDirty) this.rebuildColliders();
1491
1495
  }
1492
1496
  rebuildColliders() {
1493
1497
  this.collidersDirty = false;
1494
1498
  for (const child of [...this.children]) if (child.name.startsWith("__tileSolid")) child.free();
1499
+ this.builtScale = [this.scale[0] ?? 1, this.scale[1] ?? 1];
1495
1500
  if (this._solid.length === 0) return;
1496
1501
  const ts = this.tileSize;
1502
+ const [sx, sy] = this.builtScale;
1497
1503
  mergeSolidRects(this.grid(), new Set(this._solid)).forEach((r, i) => {
1498
1504
  const body = new StaticBody2D(`__tileSolid${i}`);
1499
- body.position = [(r.x + r.w / 2) * ts, (r.y + r.h / 2) * ts];
1505
+ body.position = [(r.x + r.w / 2) * ts * sx, (r.y + r.h / 2) * ts * sy];
1500
1506
  body.collider = {
1501
1507
  shape: "rect",
1502
- size: [r.w * ts, r.h * ts]
1508
+ size: [r.w * ts * sx, r.h * ts * sy]
1503
1509
  };
1504
1510
  this.addChild(body);
1505
1511
  });
@@ -1543,9 +1549,12 @@ var TileMap2D = class extends Node2D {
1543
1549
  mat.needsUpdate = true;
1544
1550
  this.geometryDirty = true;
1545
1551
  }
1546
- if (this.gridChecked !== this.texture) {
1547
- this.gridChecked = this.texture;
1548
- const problem = checkSheetGrid(this.texture, this.tileSize, this.tileSize, image.width, image.height);
1552
+ let highestAsked = -1;
1553
+ for (const row of this.grid()) for (const tile of row) if (tile > highestAsked) highestAsked = tile;
1554
+ const checkKey = `${this.texture}|${highestAsked}`;
1555
+ if (this.gridChecked !== checkKey) {
1556
+ this.gridChecked = checkKey;
1557
+ const problem = checkSheetGrid(this.texture, this.tileSize, this.tileSize, image.width, image.height, highestAsked >= 0 ? highestAsked : void 0, "tile");
1549
1558
  if (problem) diagnose(this.tree?.engine ?? null, "error", problem);
1550
1559
  }
1551
1560
  const columns = this.columns > 0 ? this.columns : Math.max(1, Math.floor(image.width / this.tileSize));
@@ -1,5 +1,5 @@
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-KZESOZmE.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-DZE7B07H.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";
@@ -179,6 +179,11 @@ var WebAudioTrack = class {
179
179
  setLoop(loop) {
180
180
  this.el.loop = loop;
181
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
+ }
182
187
  play() {
183
188
  this.el.play().catch((error) => {
184
189
  const name = error?.name ?? "";
@@ -243,7 +248,8 @@ const SILENT_BACKEND = {
243
248
  setGain() {},
244
249
  setLoop() {},
245
250
  play() {},
246
- stop() {}
251
+ stop() {},
252
+ playhead: () => null
247
253
  }),
248
254
  unlock() {}
249
255
  };
@@ -283,6 +289,24 @@ var MusicManager = class {
283
289
  get current() {
284
290
  return this.active?.track.src ?? null;
285
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
+ }
286
310
  /** Per-manager volume (0..1), on top of the bus gain. */
287
311
  get volume() {
288
312
  return this._volume;
@@ -1036,6 +1060,16 @@ var SfxEngine = class {
1036
1060
  if (ctx && ctx.state === "suspended") ctx.resume();
1037
1061
  }
1038
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
+ /**
1039
1073
  * Play a preset at `gain` (0..1, already bus-multiplied by the caller). Each
1040
1074
  * call spawns its own source so rapid repeats overlap instead of cutting off.
1041
1075
  * Headless → no-op.
@@ -1077,7 +1111,7 @@ var SfxEngine = class {
1077
1111
  const source = ctx.createBufferSource();
1078
1112
  source.buffer = buffer;
1079
1113
  source.connect(gainNode);
1080
- source.start();
1114
+ source.start(opts.when === void 0 ? 0 : Math.max(0, opts.when));
1081
1115
  }
1082
1116
  };
1083
1117
  //#endregion
@@ -1997,6 +2031,25 @@ var FrameStatsRing = class {
1997
2031
  const span = this.spanMs();
1998
2032
  return span > 0 ? span / (this.count - 1) : 0;
1999
2033
  }
2034
+ /**
2035
+ * How long ago the newest sample arrived, in ms. `null` with no samples.
2036
+ *
2037
+ * `fps()` is a rate over a WINDOW, so it keeps answering after the frames
2038
+ * stop: a hidden tab, a descheduled loop, a page the browser froze. Measured
2039
+ * on an engine that ticked 40 times and then stopped being ticked:
2040
+ *
2041
+ * while ticking : fps 59.9
2042
+ * after 0 more ticks : fps 59.9 <- the last sample is 668 ms old
2043
+ *
2044
+ * Both numbers are true about the window they describe. What was missing was
2045
+ * any way to ask WHEN that window was, so "60 fps" read the same whether it
2046
+ * was measured 8 ms ago or four seconds ago.
2047
+ */
2048
+ ageMs(nowMs) {
2049
+ if (this.count === 0) return null;
2050
+ const newest = this.times[(this.head + this.count - 1) % this.capacity];
2051
+ return Math.max(0, nowMs - newest);
2052
+ }
2000
2053
  /** Forget all samples (engine.stop() — a paused game has no frame rate). */
2001
2054
  clear() {
2002
2055
  this.head = 0;
@@ -2399,6 +2452,49 @@ var Engine = class {
2399
2452
  * learns about Rapier or three. From a behavior, prefer `this.physics`.
2400
2453
  */
2401
2454
  physics = null;
2455
+ /**
2456
+ * How to build a physics world for a scene that needs one.
2457
+ *
2458
+ * `createGame2D`/`createGame3D` install this; core never learns what Rapier
2459
+ * is. It exists because `physics: 'auto'` can only look at the scene the game
2460
+ * BOOTED with, and the most universal act of finishing a game is putting a
2461
+ * title screen in front of it:
2462
+ *
2463
+ * ```
2464
+ * after boot on the title : engine.physics = null
2465
+ * after goToScene into game: engine.physics = null
2466
+ * player y after 2 s : 0 -> 0
2467
+ * ```
2468
+ *
2469
+ * The game rendered perfectly and was inert — nothing fell, nothing collided,
2470
+ * nothing could be collected or hurt. One scene swap turned a working game
2471
+ * into a diorama, and nothing was logged.
2472
+ */
2473
+ physicsProvider = null;
2474
+ ensuring = null;
2475
+ /**
2476
+ * Give the current scene a physics world if it needs one and has none.
2477
+ *
2478
+ * Idempotent and safe to call on every swap: the provider decides whether the
2479
+ * incoming tree actually wants physics, and an explicit `physics: false` at
2480
+ * boot installs no provider at all, so opting out stays opted out.
2481
+ *
2482
+ * Asynchronous because the Rapier WASM has to load. The first frames after a
2483
+ * swap therefore run unsimulated — which every AI behaviour already tolerates
2484
+ * (`moveBody` falls back to a direct write when `_physics` is null) and which
2485
+ * is a hitch, where the alternative was a game that never simulated at all.
2486
+ */
2487
+ ensurePhysics() {
2488
+ if (this.physics || !this.physicsProvider || !this._scene) return Promise.resolve();
2489
+ if (this.ensuring) return this.ensuring;
2490
+ const root = this._scene.root;
2491
+ this.ensuring = this.physicsProvider(root).catch((e) => {
2492
+ this.log.error(`[incanto] could not enable physics for this scene: ${String(e?.message ?? e)}`);
2493
+ }).finally(() => {
2494
+ this.ensuring = null;
2495
+ });
2496
+ return this.ensuring;
2497
+ }
2402
2498
  /** Seeded randomness for game logic (deterministic when `seed` is set). */
2403
2499
  rng;
2404
2500
  /** The engine log channel (debug overlay + headless harness tail this). */
@@ -2722,6 +2818,7 @@ var Engine = class {
2722
2818
  return {
2723
2819
  fps: this.frameStats.fps(),
2724
2820
  frameMs: this.frameStats.frameMs(),
2821
+ lastFrameAgeMs: this.frameStats.ageMs(performance.now()),
2725
2822
  nodes: this._scene?.tree.root ? countNodes(this._scene.tree.root) : 0,
2726
2823
  running: this.disposeScheduler !== null,
2727
2824
  errors: this.errorCount,
@@ -2774,6 +2871,7 @@ var Engine = class {
2774
2871
  this._time += dt;
2775
2872
  this._unscaledTime += this.fixedStep;
2776
2873
  const startedFixed = performance.now();
2874
+ this.input.beginFrame();
2777
2875
  scene.tree.fixedUpdate(dt);
2778
2876
  this.fixedUpdated.emit(dt);
2779
2877
  const startedUpdate = performance.now();
@@ -2801,6 +2899,7 @@ var Engine = class {
2801
2899
  this.lastMs = nowMs;
2802
2900
  const scene = this._scene;
2803
2901
  if (!scene) return;
2902
+ this.input.beginFrame();
2804
2903
  this.accumulator += dt;
2805
2904
  let steps = 0;
2806
2905
  const startedFixed = performance.now();
@@ -2973,7 +3072,8 @@ var AudioPlayer = class extends Node {
2973
3072
  name,
2974
3073
  from: this.getPath(),
2975
3074
  bus: this.bus === "music" ? "music" : "sfx",
2976
- at: engine.time
3075
+ at: engine.time,
3076
+ ...this.loop ? { loop: true } : {}
2977
3077
  });
2978
3078
  }
2979
3079
  /** Final gain = engine buses × this volume (1 when not in a tree). */
@@ -3005,6 +3105,54 @@ var AudioPlayer = class extends Node {
3005
3105
  rolloff: this.rolloff
3006
3106
  };
3007
3107
  }
3108
+ /**
3109
+ * Start the sound, now.
3110
+ *
3111
+ * **Takes no arguments, on purpose.** This is the method scenes wire signals
3112
+ * to — `incanto-audio.md` teaches
3113
+ * `{"signal": "collected", "from": "Player/Collector", "to": "Coin",
3114
+ * "handler": "play"}` — and a signal hands its handler whatever it carries.
3115
+ * `collected` leads with a NUMBER. When this briefly took an optional
3116
+ * scheduling time, that wiring became `play(10)`, which scheduled the pickup
3117
+ * sound at ABSOLUTE audio-clock second 10: inaudible for the first ten
3118
+ * seconds of the game and fine thereafter. The same optional parameter also
3119
+ * made `Function.length` 1, so the engine's own arity checker cried wolf on
3120
+ * the shipped `platformer-2d` template's own harness.
3121
+ *
3122
+ * Scheduling has its own name: {@link playAt}.
3123
+ */
3124
+ /**
3125
+ * Start the sound at `when` on the AUDIO clock (`engine.sfx.now` + a lead).
3126
+ *
3127
+ * A frame is a 16.67 ms grid at 60 Hz and 33.33 ms at 30, so a sound fired
3128
+ * from `update()` cannot land closer than one frame to where a chart wants
3129
+ * it — measured 0 of 64 notes given a scheduled start. Queue them a lead
3130
+ * ahead and the same notes land at |mean| 0.000000 ms:
3131
+ *
3132
+ * ```ts
3133
+ * const LEAD = 0.08;
3134
+ * for (const note of dueSoon(engine.sfx.now + LEAD)) {
3135
+ * hit.playAt(startedAt + note.atSec);
3136
+ * }
3137
+ * ```
3138
+ *
3139
+ * PRESETS only — a `src` clip goes through an `<audio>` element, which has no
3140
+ * scheduling clock, so `playAt` on one plays immediately rather than being
3141
+ * silently approximated. A time already past plays now (Web Audio's own
3142
+ * rule), so a late scheduler is late, not silent. The bus gain, the
3143
+ * `engine.audio.recent()` entry and `muted` all still apply.
3144
+ *
3145
+ * Separate from {@link play} because `play` is what scenes wire signals to,
3146
+ * and a signal hands its handler whatever it carries.
3147
+ */
3148
+ playAt(when) {
3149
+ if (this.preset !== "custom") {
3150
+ this.note("preset", this.preset);
3151
+ this.playPreset(Number.isFinite(when) ? when : void 0);
3152
+ return;
3153
+ }
3154
+ this.play();
3155
+ }
3008
3156
  play() {
3009
3157
  if (this.preset !== "custom") {
3010
3158
  this.note("preset", this.preset);
@@ -3052,7 +3200,7 @@ var AudioPlayer = class extends Node {
3052
3200
  * the length is arithmetic, not a device. WebAudio needs a gesture; a
3053
3201
  * suspended context is resumed by the same unlock listener as `src` players.
3054
3202
  */
3055
- playPreset() {
3203
+ playPreset(at) {
3056
3204
  const params = SFX_PRESETS[this.preset];
3057
3205
  if (!params) return;
3058
3206
  const engine = this.tree?.engine;
@@ -3063,7 +3211,8 @@ var AudioPlayer = class extends Node {
3063
3211
  sfx.play(params, this.gain(), {
3064
3212
  pitch: this.pitch,
3065
3213
  seed: this.seed,
3066
- spatial: this.spatialPlay()
3214
+ spatial: this.spatialPlay(),
3215
+ when: at
3067
3216
  });
3068
3217
  }
3069
3218
  /**
@@ -3099,6 +3248,10 @@ var AudioPlayer = class extends Node {
3099
3248
  }
3100
3249
  if (this.presetEndsAt !== null) {
3101
3250
  if ((this.tree?.engine?.unscaledTime ?? 0) >= this.presetEndsAt) {
3251
+ if (this.loop) {
3252
+ this.playPreset();
3253
+ return;
3254
+ }
3102
3255
  this.presetEndsAt = null;
3103
3256
  this._playing = false;
3104
3257
  this.emit("finished");
@@ -1,5 +1,5 @@
1
- import { D as handlerShadowWarnings, j as nodeRefWarnings } from "./loader-KZESOZmE.js";
2
- import { R as translationKey, k as HudWidgetBase } from "./register-Cs53CY7s.js";
1
+ import { D as handlerShadowWarnings, j as nodeRefWarnings } from "./loader-DZE7B07H.js";
2
+ import { R as translationKey, k as HudWidgetBase } from "./register-mmfugpaQ.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,8 +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
- const isFragment = cameras === 0;
170
- 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\": \"...\" }.");
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.");
171
194
  if (damageGroups.length > 0) {
172
195
  const hurtable = hurtableGroups(root);
173
196
  for (const { path, group } of damageGroups) {
@@ -359,4 +382,4 @@ function replay(engine, recording, opts) {
359
382
  }
360
383
  }
361
384
  //#endregion
362
- export { startRecording as n, auditScene as r, replay as t };
385
+ export { instancedScenes as i, startRecording as n, auditScene as r, replay as t };