incanto 0.56.0 → 0.58.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 (100) hide show
  1. package/README.md +6 -4
  2. package/bin/incanto-check.mjs +27 -0
  3. package/bin/incanto-new.mjs +29 -7
  4. package/bin/incanto-playtest.mjs +28 -2
  5. package/bin/incanto-verify.mjs +96 -17
  6. package/bin/incanto.mjs +106 -0
  7. package/dist/2d.d.ts +59 -7
  8. package/dist/2d.js +3 -3
  9. package/dist/3d.d.ts +34 -4
  10. package/dist/3d.js +5 -5
  11. package/dist/{behavior-CyQoSu4n.d.ts → behavior-l08AEbq9.d.ts} +5 -0
  12. package/dist/{create-game-YvaNWhe6.js → create-game-BZwWJIns.js} +146 -7
  13. package/dist/{create-game-CqJkfMzm.js → create-game-D10bU5_J.js} +83 -78
  14. package/dist/debug.d.ts +1 -1
  15. package/dist/{duplicate-MNLMAcbz.js → duplicate-BPLZDZpd.js} +1 -1
  16. package/dist/{environment-presets-DLHqgNAF.js → environment-presets-CvvQr_bJ.js} +106 -24
  17. package/dist/{frame-report-njybhZon.js → frame-report-BSMny7oe.js} +1 -1
  18. package/dist/{frame-report-DZ70IY26.d.ts → frame-report-DCnHFmto.d.ts} +1 -1
  19. package/dist/{gameplay-C_SPzmUe.js → gameplay-BVphcxmE.js} +89 -13
  20. package/dist/gameplay.d.ts +26 -1
  21. package/dist/gameplay.js +1 -1
  22. package/dist/index.d.ts +45 -5
  23. package/dist/index.js +8 -9
  24. package/dist/{loader-DhI1jFW_.d.ts → loader-BbEMTuWg.d.ts} +1 -1
  25. package/dist/{loader-BTkHYrQn.js → loader-BcrRSjxB.js} +682 -682
  26. package/dist/net.d.ts +2 -2
  27. package/dist/net.js +1 -1
  28. package/dist/{pathfinding-CXGCpRQe.d.ts → pathfinding-mEN4V1CU.d.ts} +1 -1
  29. package/dist/{physics-2d-CfWAggJ1.js → physics-2d-EqA-jddf.js} +4 -3
  30. package/dist/{physics-3d-NzDwWPrF.js → physics-3d-Dnz4fsXX.js} +5 -4
  31. package/dist/quiet-rapier-BAJ4K94N.js +46 -0
  32. package/dist/react.d.ts +1 -1
  33. package/dist/react.js +1 -1
  34. package/dist/{register-en63AEZO.js → register-C6ZBFRjd.js} +63 -60
  35. package/dist/{register-p48lHE2o.js → register-Ch70uByv.js} +1615 -1502
  36. package/dist/{replay-ePMz26jw.d.ts → replay-Dw6gMlYA.d.ts} +1 -1
  37. package/dist/{replay-t1pP0gQg.js → replay-s7I2GstT.js} +31 -11
  38. package/dist/sheet-grid-BT6N_Bjs.js +59 -0
  39. package/dist/{split-screen-BsdOHbzP.d.ts → split-screen-B0baBwxI.d.ts} +5 -1
  40. package/dist/{split-screen-CSb_uZ6W.js → split-screen-DLsUrleX.js} +14 -3
  41. package/dist/{sprite-animation-7qvUxF6Z.js → sprite-animation-C0wXLBZJ.js} +8 -4
  42. package/dist/{src-51GA-9m-.js → src-C1J09Op6.js} +55 -2
  43. package/dist/{teardown-C7uVSJvx.js → teardown-Cs113S9F.js} +74 -2
  44. package/dist/{test-Bu6SPNsV.js → test-ZBga8kQ9.js} +60 -25
  45. package/dist/test.d.ts +5 -5
  46. package/dist/test.js +2 -2
  47. package/dist/{touch-BnMyy9tr.js → touch-DESwnpOc.js} +173 -1
  48. package/dist/vite.d.ts +1 -1
  49. package/dist/vite.js +3 -3
  50. package/editor/assets/{agent8-X0Nesj-k.js → agent8-BQQjE9UQ.js} +1 -1
  51. package/editor/assets/{debug-TXqu-ftX.js → debug-C9UCsXBS.js} +1 -1
  52. package/editor/assets/{index-DTdwKKIv.js → index-CBgfM3WD.js} +62 -62
  53. package/editor/index.html +1 -1
  54. package/package.json +2 -1
  55. package/skills/incanto-assets.md +5 -1
  56. package/skills/incanto-audio.md +7 -1
  57. package/skills/incanto-building-2d-games.md +13 -0
  58. package/skills/incanto-building-3d-games.md +3 -3
  59. package/skills/incanto-gameplay-behaviors.md +2 -1
  60. package/skills/incanto-hud.md +6 -0
  61. package/skills/incanto-performance.md +5 -0
  62. package/skills/incanto-physics-and-input.md +14 -0
  63. package/skills/incanto-playtesting.md +19 -5
  64. package/skills/incanto-save-slots.md +2 -1
  65. package/skills/incanto-verifying-your-game.md +15 -4
  66. package/templates-app/beacon-isle-3d/package.json +1 -1
  67. package/templates-app/beacon-isle-3d/src/game.scene.json +7 -6
  68. package/templates-app/platformer-2d/PROJECT/Context.md +70 -0
  69. package/templates-app/platformer-2d/PROJECT/Requirements.md +63 -0
  70. package/templates-app/platformer-2d/PROJECT/Status.md +60 -0
  71. package/templates-app/platformer-2d/PROJECT/Structure.md +77 -0
  72. package/templates-app/platformer-2d/docs/project-2d-rules.md +61 -0
  73. package/templates-app/platformer-2d/index.html +99 -0
  74. package/templates-app/platformer-2d/package.json +23 -0
  75. package/templates-app/platformer-2d/src/behaviors.ts +541 -0
  76. package/templates-app/platformer-2d/src/game.scene.json +2061 -0
  77. package/templates-app/platformer-2d/src/main.ts +68 -0
  78. package/templates-app/platformer-2d/tsconfig.json +13 -0
  79. package/templates-app/platformer-2d/verify.ts +275 -0
  80. package/templates-app/platformer-2d/vite.config.ts +12 -0
  81. package/templates-app/star-survivor/PROJECT/Context.md +55 -0
  82. package/templates-app/star-survivor/PROJECT/Requirements.md +47 -0
  83. package/templates-app/star-survivor/PROJECT/Status.md +44 -0
  84. package/templates-app/star-survivor/PROJECT/Structure.md +63 -0
  85. package/templates-app/star-survivor/docs/project-2d-rules.md +53 -0
  86. package/templates-app/star-survivor/index.html +232 -0
  87. package/templates-app/star-survivor/package.json +23 -0
  88. package/templates-app/star-survivor/src/behaviors.ts +624 -0
  89. package/templates-app/star-survivor/src/game.scene.json +464 -0
  90. package/templates-app/star-survivor/src/main.ts +49 -0
  91. package/templates-app/star-survivor/tsconfig.json +13 -0
  92. package/templates-app/star-survivor/verify.ts +193 -0
  93. package/templates-app/star-survivor/vite.config.ts +12 -0
  94. package/templates-app/tps-3d/package.json +1 -1
  95. package/templates-app/tps-3d/src/game.scene.json +6 -3
  96. package/templates-app/tps-3d/verify.ts +17 -1
  97. package/templates-app/village-quest-3d/package.json +1 -1
  98. package/templates-app/village-quest-3d/src/grove.scene.json +14 -13
  99. package/templates-app/village-quest-3d/src/village.scene.json +5 -5
  100. package/dist/log-report-CPFm4OXf.js +0 -173
@@ -1,4 +1,4 @@
1
- import { b as Engine } from "./behavior-CyQoSu4n.js";
1
+ import { b as Engine } from "./behavior-l08AEbq9.js";
2
2
  import { s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/core/audit.d.ts
@@ -1,6 +1,6 @@
1
- import { E as behaviorSchema, w as parseNodePath } from "./loader-BTkHYrQn.js";
2
- import { P as translationKey } from "./register-p48lHE2o.js";
3
- import { i as getNodeSchema } from "./registry-C7u42TID.js";
1
+ import { E as behaviorSchema, w as parseNodePath } from "./loader-BcrRSjxB.js";
2
+ import { D as HudWidgetBase, F as translationKey } from "./register-Ch70uByv.js";
3
+ import { i as getNodeSchema, o as getNodeType } from "./registry-C7u42TID.js";
4
4
  //#region src/core/node-refs.ts
5
5
  /**
6
6
  * Node-path PROPS, resolved against scene JSON.
@@ -211,13 +211,33 @@ const BODY_TYPES = new Set([
211
211
  "Area3D",
212
212
  "CharacterBody3D"
213
213
  ]);
214
- const HUD_WIDGETS = new Set([
215
- "UiText",
216
- "UiBar",
217
- "UiBanner",
218
- "UiButton",
219
- "UiDialogue"
220
- ]);
214
+ /**
215
+ * Is this node type a HUD widget — one that renders NOTHING outside a HudLayer?
216
+ *
217
+ * This used to be a hand-written list of five, and the kit has sixteen. It went
218
+ * stale the moment UiPanel landed, so the settings menu in the performance
219
+ * skill and the volume menu in the audio skill — both bare `UiPanel`s of
220
+ * uncovered widgets — drew a blank screen while `incanto-check` printed `ok`.
221
+ * The runtime is silent by design (`hud.ts`: `if (!host) return;`), which is
222
+ * exactly why the lint has to be right.
223
+ *
224
+ * Derived from the class hierarchy instead: anything descending from
225
+ * `HudWidgetBase` is one, whatever it is called and whenever it was added.
226
+ */
227
+ function isHudWidget(type) {
228
+ if (type.startsWith("Ui")) return true;
229
+ let ctor;
230
+ try {
231
+ ctor = getNodeType(type);
232
+ } catch {
233
+ return false;
234
+ }
235
+ for (let c = ctor; c; c = Object.getPrototypeOf(c)) {
236
+ if (c.typeName === "HudWidgetBase") return true;
237
+ if (c === HudWidgetBase) return true;
238
+ }
239
+ return false;
240
+ }
221
241
  const LIGHT_TYPES = new Set([
222
242
  "DirectionalLight3D",
223
243
  "OmniLight3D",
@@ -269,7 +289,7 @@ function auditScene(scene) {
269
289
  const collider = props.collider;
270
290
  if (!collider || collider.shape === void 0) warnings.push(`${path}: ${type} has no collider — physics will skip it (add "collider": { "shape": ... }).`);
271
291
  }
272
- if (HUD_WIDGETS.has(type) && !insideHud) warnings.push(`${path}: ${type} works only as a descendant of a HudLayer — it will not render.`);
292
+ if (isHudWidget(type) && !insideHud) warnings.push(`${path}: ${type} works only as a descendant of a HudLayer — it will not render.`);
273
293
  if (staticRoot && ANIMATED_TYPES.has(type)) warnings.push(`${path}: ${type} is frozen inside static subtree '${staticRoot}' — it will not animate/update on screen.`);
274
294
  const nextStatic = staticRoot ?? (props.static === true ? node.name ?? path : null);
275
295
  const nextHud = insideHud || type === "HudLayer";
@@ -0,0 +1,59 @@
1
+ //#region src/core/sheet-grid.ts
2
+ /**
3
+ * Does this spritesheet's grid actually fit the image?
4
+ *
5
+ * `frameWidth`/`frameHeight` are taken on trust, and the frame math floors the
6
+ * division — so a frame size that does not divide the sheet slices every row a
7
+ * little further off, and an animation naming a frame past the end of the grid
8
+ * reads whatever texels are there. Both draw WRONG ART with no error anywhere,
9
+ * which is the one symptom an author cannot tell apart from having picked the
10
+ * wrong sheet in the first place.
11
+ *
12
+ * Pure arithmetic on numbers that are known the moment the image loads, so it
13
+ * costs nothing and it is exact.
14
+ *
15
+ * It lives in core because THREE nodes do this division — AnimatedSprite2D,
16
+ * AnimatedSprite3D and TileMap2D — and for a while the checker sat in `2d/`
17
+ * where only one of them could see it. A checker one directory away from the
18
+ * bug it catches is the same as no checker.
19
+ */
20
+ /**
21
+ * Frame sizes that divide `total` cleanly, biggest first.
22
+ *
23
+ * Bounded to grids of 2–32 cells so the suggestion is a frame size someone
24
+ * might actually have meant, not every factor of the number.
25
+ */
26
+ function divisors(total) {
27
+ const out = [];
28
+ for (let cells = 2; cells <= 32; cells++) if (total % cells === 0) out.push(total / cells);
29
+ return out.sort((a, b) => b - a).slice(0, 6);
30
+ }
31
+ /**
32
+ * The problem with this grid, or null when there is none.
33
+ *
34
+ * `highestFrame` is the largest frame index the scene asks for, when it is
35
+ * known — an animation range that runs off the end is the other half of the
36
+ * same mistake.
37
+ */
38
+ function checkSheetGrid(key, frameWidth, frameHeight, imageWidth, imageHeight, highestFrame) {
39
+ if (!imageWidth || !imageHeight || !frameWidth || !frameHeight) return null;
40
+ if (imageWidth % frameWidth !== 0 || imageHeight % frameHeight !== 0) {
41
+ const parts = [];
42
+ if (imageWidth % frameWidth !== 0) {
43
+ const fits = divisors(imageWidth);
44
+ parts.push(`${imageWidth} px wide is not a whole number of ${frameWidth} px frames` + (fits.length > 0 ? ` (${fits.join(", ")} would divide it)` : ""));
45
+ }
46
+ if (imageHeight % frameHeight !== 0) {
47
+ const fits = divisors(imageHeight);
48
+ parts.push(`${imageHeight} px tall is not a whole number of ${frameHeight} px frames` + (fits.length > 0 ? ` (${fits.join(", ")} would divide it)` : ""));
49
+ }
50
+ return `[incanto] spritesheet '${key}' does not fit its frame size: ${parts.join("; ")}. Every row after the first is cut off centre.`;
51
+ }
52
+ if (highestFrame !== void 0) {
53
+ const frames = imageWidth / frameWidth * (imageHeight / frameHeight);
54
+ if (highestFrame >= frames) return `[incanto] spritesheet '${key}' has ${frames} frames (0–${frames - 1}), and an animation asks for frame ${highestFrame}. Those frames draw whatever is at the wrong end of the sheet.`;
55
+ }
56
+ return null;
57
+ }
58
+ //#endregion
59
+ export { checkSheetGrid as t };
@@ -1,4 +1,4 @@
1
- import { At as Node, It as Signal, b as Engine } from "./behavior-CyQoSu4n.js";
1
+ import { At as Node, It as Signal, b as Engine } from "./behavior-l08AEbq9.js";
2
2
  import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/net/types.d.ts
@@ -317,6 +317,10 @@ declare class NetworkManager {
317
317
  private readonly latestGlobalCollections;
318
318
  private readonly scenes;
319
319
  private readonly lastSent;
320
+ /** Sync keys already reported unreadable, so a per-frame send says it once. */
321
+ private readonly reportedDeadKeys;
322
+ /** Name a sync key the owner cannot read — a typo in the prop or the path. */
323
+ private reportDeadSyncKey;
320
324
  private sendAccumulator;
321
325
  private detachReplication;
322
326
  private lastOwner;
@@ -1,5 +1,5 @@
1
- import { C as diagnose, M as Signal, f as Node, n as loadScene, t as buildNodeJson } from "./loader-BTkHYrQn.js";
2
- import { t as registerCoreNodes, x as Engine } from "./register-p48lHE2o.js";
1
+ import { C as diagnose, M as Signal, S as Node, n as loadScene, t as buildNodeJson } from "./loader-BcrRSjxB.js";
2
+ import { _ as Engine, t as registerCoreNodes } from "./register-Ch70uByv.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
5
  import { l as registerNode } from "./registry-C7u42TID.js";
@@ -811,6 +811,14 @@ var NetworkManager = class NetworkManager {
811
811
  latestGlobalCollections = /* @__PURE__ */ new Map();
812
812
  scenes = /* @__PURE__ */ new Map();
813
813
  lastSent = /* @__PURE__ */ new Map();
814
+ /** Sync keys already reported unreadable, so a per-frame send says it once. */
815
+ reportedDeadKeys = /* @__PURE__ */ new Set();
816
+ /** Name a sync key the owner cannot read — a typo in the prop or the path. */
817
+ reportDeadSyncKey(owner, key) {
818
+ if (this.reportedDeadKeys.has(key)) return;
819
+ this.reportedDeadKeys.add(key);
820
+ diagnose(owner.tree?.engine ?? null, "error", `[incanto] ${owner.getPath()}: sync key '${key}' reads as undefined — it is never sent, so remote players never see it change. Check the prop name and the node path.`);
821
+ }
814
822
  sendAccumulator = 0;
815
823
  detachReplication = null;
816
824
  lastOwner = null;
@@ -1000,7 +1008,10 @@ var NetworkManager = class NetworkManager {
1000
1008
  const patch = {};
1001
1009
  for (const key of syncKeys) {
1002
1010
  const value = readSyncKey(owner, key);
1003
- if (value === void 0) continue;
1011
+ if (value === void 0) {
1012
+ this.reportDeadSyncKey(owner, key);
1013
+ continue;
1014
+ }
1004
1015
  if (!jsonEquals(this.lastSent.get(key) ?? null, value)) patch[key] = jsonClone(value);
1005
1016
  }
1006
1017
  if (this.sendAccumulator < window) return;
@@ -306,10 +306,14 @@ function resolveAnimation(animations, name, owner) {
306
306
  const via = seen.length > 0 ? ` (via ${seen.join(" → ")})` : "";
307
307
  throw new IncantoError("UNKNOWN_ANIMATION", `No animation '${key}' on '${owner}'${via}. Available: [${Object.keys(animations).join(", ")}].`);
308
308
  }
309
- if (typeof entry !== "string") return {
310
- name: key,
311
- def: entry
312
- };
309
+ if (typeof entry !== "string") {
310
+ const frames = resolveFrames(entry.frames, key);
311
+ if (frames.length > 1 && !(Number.isFinite(entry.fps) && entry.fps > 0)) throw new IncantoError("BAD_FORMAT", `Animation '${key}' on '${owner}' has ${frames.length} frames and no "fps" — it would sit on the first one forever. Add "fps" (the field is fps, not frameRate or speed).`);
312
+ return {
313
+ name: key,
314
+ def: entry
315
+ };
316
+ }
313
317
  if (seen.includes(key)) throw new IncantoError("UNKNOWN_ANIMATION", `Animation aliases on '${owner}' point in a circle: ${[...seen, key].join(" → ")}.`);
314
318
  seen.push(key);
315
319
  key = entry;
@@ -143,6 +143,59 @@ async function preloadUrls(urls, onProgress) {
143
143
  function assetUrls(assets) {
144
144
  return Object.values(assets ?? {}).map((a) => a.url).filter((u) => typeof u === "string" && !u.startsWith("data:"));
145
145
  }
146
+ /**
147
+ * The standard boot screen every template hand-rolled (~125 identical lines
148
+ * of HTML+CSS): black overlay, "Loading" title, progress track + percent.
149
+ * Headless it returns inert no-ops.
150
+ */
151
+ function createLoadingOverlay(title = "Loading") {
152
+ if (typeof document === "undefined") return {
153
+ setProgress: () => {},
154
+ remove: () => {}
155
+ };
156
+ const wrap = document.createElement("div");
157
+ wrap.style.cssText = "position:fixed;inset:0;z-index:10000;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000;color:#fff;font:600 15px system-ui, -apple-system, 'Segoe UI', sans-serif;transition:opacity .3s;";
158
+ const h = document.createElement("div");
159
+ h.textContent = title;
160
+ h.style.cssText = "font-size:1.5rem;font-weight:700;margin-bottom:1rem;";
161
+ const track = document.createElement("div");
162
+ track.style.cssText = "width:256px;height:8px;background:#374151;border-radius:9999px;overflow:hidden;";
163
+ const fill = document.createElement("div");
164
+ fill.style.cssText = "width:0%;height:100%;background:#3b82f6;transition:width .3s;";
165
+ track.appendChild(fill);
166
+ const pct = document.createElement("div");
167
+ pct.textContent = "0%";
168
+ pct.style.cssText = "margin-top:.5rem;font-size:.9rem;";
169
+ wrap.append(h, track, pct);
170
+ document.body.appendChild(wrap);
171
+ let removed = false;
172
+ return {
173
+ setProgress(done, total) {
174
+ const p = total > 0 ? Math.min(100, Math.round(done / total * 100)) : 100;
175
+ fill.style.width = `${p}%`;
176
+ pct.textContent = `${p}%`;
177
+ },
178
+ remove() {
179
+ if (removed) return;
180
+ removed = true;
181
+ wrap.style.opacity = "0";
182
+ setTimeout(() => wrap.remove(), 320);
183
+ }
184
+ };
185
+ }
186
+ /**
187
+ * Preload every scene-asset URL behind the standard overlay, then remove it:
188
+ *
189
+ * await preloadSceneAssets(sceneJson.assets);
190
+ */
191
+ async function preloadSceneAssets(assets, title) {
192
+ const overlay = createLoadingOverlay(title);
193
+ try {
194
+ await preloadUrls(assetUrls(assets), (done, total) => overlay.setProgress(done, total));
195
+ } finally {
196
+ overlay.remove();
197
+ }
198
+ }
146
199
  //#endregion
147
200
  //#region src/core/uid-gen.ts
148
201
  /**
@@ -161,6 +214,6 @@ function newUid() {
161
214
  //#endregion
162
215
  //#region src/index.ts
163
216
  /** Engine version. Kept in sync with package.json by the release pipeline. */
164
- const VERSION = "0.56.0";
217
+ const VERSION = "0.58.0";
165
218
  //#endregion
166
- export { findPath as a, preloadUrls as i, newUid as n, gridFromRows as o, assetUrls as r, VERSION as t };
219
+ export { preloadUrls as a, preloadSceneAssets as i, newUid as n, findPath as o, assetUrls as r, gridFromRows as s, VERSION as t };
@@ -1,4 +1,6 @@
1
- import { b as AudioPlayer } from "./register-p48lHE2o.js";
1
+ import { g as AudioPlayer } from "./register-Ch70uByv.js";
2
+ import { a as logReport, s as parseDrive } from "./touch-DESwnpOc.js";
3
+ import { a as frameSignature, i as frameImage, o as frameStats } from "./frame-report-BSMny7oe.js";
2
4
  //#region src/core/audio-errors.ts
3
5
  /** Every AudioPlayer under `root` whose source could not be played. */
4
6
  function audioErrors(root) {
@@ -27,6 +29,76 @@ function claimCanvasGestures(canvas) {
27
29
  style.webkitTapHighlightColor = "transparent";
28
30
  }
29
31
  //#endregion
32
+ //#region src/core/dev-channel.ts
33
+ /**
34
+ * Answer the dev server's frame/log requests. Returns the unsubscribes, or an
35
+ * empty list outside a dev server.
36
+ */
37
+ function wireDevChannel(opts) {
38
+ const hot = import.meta.hot;
39
+ const cleanups = [];
40
+ if (!hot) return cleanups;
41
+ const onFrameRequest = (data) => {
42
+ const request = data;
43
+ const id = request?.id;
44
+ hot.send("incanto:frame-ack", { id });
45
+ const drive = request?.drive ? parseDrive(request.drive) : null;
46
+ if (drive?.error) {
47
+ hot.send("incanto:frame-report", {
48
+ id,
49
+ report: { error: drive.error }
50
+ });
51
+ return;
52
+ }
53
+ let droveFrames = null;
54
+ (drive ? opts.applyDrive(drive.steps) : Promise.resolve(null)).then((frames) => {
55
+ droveFrames = frames;
56
+ return opts.captureFramePixels();
57
+ }).then((shot) => hot.send("incanto:frame-report", {
58
+ id,
59
+ report: {
60
+ ...frameStats(shot.pixels, shot.width, shot.height, { grid: request?.grid }),
61
+ signature: frameSignature(shot.pixels, shot.width, shot.height),
62
+ ...droveFrames === null ? {} : { droveFrames },
63
+ ...request?.image ? { image: frameImage(shot.pixels, shot.width, shot.height, (w, h) => Object.assign(document.createElement("canvas"), {
64
+ width: w,
65
+ height: h
66
+ }), request.image) } : {}
67
+ }
68
+ })).catch((error) => hot.send("incanto:frame-report", {
69
+ id,
70
+ report: { error: error instanceof Error ? error.message : String(error) }
71
+ }));
72
+ };
73
+ hot.on("incanto:frame-request", onFrameRequest);
74
+ cleanups.push(() => hot.off?.("incanto:frame-request", onFrameRequest));
75
+ const onLogsRequest = (data) => {
76
+ const id = data?.id;
77
+ try {
78
+ hot.send("incanto:logs-report", {
79
+ id,
80
+ report: logReport({
81
+ entries: opts.engine.log.entries(),
82
+ stats: {
83
+ ...opts.engine.stats(),
84
+ ...opts.rendererStats?.() ?? {}
85
+ },
86
+ assetErrors: opts.assetErrors?.() ?? [],
87
+ hidden: typeof document !== "undefined" && document.hidden
88
+ })
89
+ });
90
+ } catch (error) {
91
+ hot.send("incanto:logs-report", {
92
+ id,
93
+ report: { error: error instanceof Error ? error.message : String(error) }
94
+ });
95
+ }
96
+ };
97
+ hot.on("incanto:logs-request", onLogsRequest);
98
+ cleanups.push(() => hot.off?.("incanto:logs-request", onLogsRequest));
99
+ return cleanups;
100
+ }
101
+ //#endregion
30
102
  //#region src/core/editor-switch.ts
31
103
  /**
32
104
  * Cover the page while it changes what it is.
@@ -324,4 +396,4 @@ function defaultReport(what, error) {
324
396
  console.error(`[incanto] teardown: ${what} failed (continuing)`, error);
325
397
  }
326
398
  //#endregion
327
- export { openBundledEditor as a, audioErrors as c, devServerLibrary as i, pauseWhenHidden as n, poseFromRenderer as o, crossFade as r, claimCanvasGestures as s, teardown as t };
399
+ export { openBundledEditor as a, claimCanvasGestures as c, devServerLibrary as i, audioErrors as l, pauseWhenHidden as n, poseFromRenderer as o, crossFade as r, wireDevChannel as s, teardown as t };
@@ -1,13 +1,13 @@
1
- import { A as registerBehavior, n as loadScene, s as resolveViewport } from "./loader-BTkHYrQn.js";
2
- import { x as Engine } from "./register-p48lHE2o.js";
1
+ import { A as registerBehavior, n as loadScene, s as resolveViewport } from "./loader-BcrRSjxB.js";
2
+ import { _ as Engine } from "./register-Ch70uByv.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
5
5
  import { i as getNodeSchema, s as mergeStaticProps } from "./registry-C7u42TID.js";
6
- import { n as startRecording } from "./replay-t1pP0gQg.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-C_SPzmUe.js";
8
- import { t as registerNodes2D } from "./register-en63AEZO.js";
9
- import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-DLHqgNAF.js";
10
- import { n as registerNodesNet, t as createSplitScreen } from "./split-screen-CSb_uZ6W.js";
6
+ import { n as startRecording } from "./replay-s7I2GstT.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-BVphcxmE.js";
8
+ import { t as registerNodes2D } from "./register-C6ZBFRjd.js";
9
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-CvvQr_bJ.js";
10
+ import { n as registerNodesNet, t as createSplitScreen } from "./split-screen-DLsUrleX.js";
11
11
  import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
12
12
  //#region src/test/framing.ts
13
13
  /**
@@ -474,6 +474,24 @@ function framingText(report) {
474
474
  const FALL_2D_PX = 1e3;
475
475
  /** "Reached it", in pixels — one 32 px tile, next to a ~34 px character. */
476
476
  const REACH_2D_PX = 32;
477
+ /**
478
+ * How hard this scene pulls, in its own units — 0 only for a scene that says so.
479
+ *
480
+ * Gravity is what makes "left the world downward" mean anything, and a
481
+ * top-down game has none. But ABSENT is not ZERO: the physics adapters default
482
+ * an undeclared scene to [0, 980] px/s² in 2D and [0, -9.81, 0] m/s² in 3D, so
483
+ * a platformer that never wrote a physics header still falls, and reading
484
+ * absent as zero would have stopped reporting the pit it fell into.
485
+ */
486
+ function gravityMagnitude(physics) {
487
+ const g = physics?.gravity;
488
+ if (g === void 0) return 1;
489
+ if (!Array.isArray(g)) return 1;
490
+ return g.reduce((sum, v) => sum + (typeof v === "number" ? Math.abs(v) : 0), 0);
491
+ }
492
+ /** Furthest the player ever got from its spawn, below which it went nowhere. */
493
+ const STUCK_3D_M = 3;
494
+ const STUCK_2D_PX = 96;
477
495
  const CONTROLLERS = new Set(["CharacterController2D", "CharacterController3D"]);
478
496
  /**
479
497
  * Who the driver is playing as.
@@ -718,7 +736,8 @@ async function runOnce(json, seed, opts) {
718
736
  0
719
737
  ];
720
738
  const down = scene.dimension === "2d" ? 1 : -1;
721
- const floor = opts.fallBelow ?? start[1] + down * (down > 0 ? FALL_2D_PX : 50);
739
+ const pull = gravityMagnitude(scene.physics);
740
+ const floor = opts.fallBelow ?? (pull > 0 ? start[1] + down * (down > 0 ? FALL_2D_PX : 50) : null);
722
741
  const radius = opts.reachRadius ?? (down > 0 ? REACH_2D_PX : 2);
723
742
  const recorder = startRecording(engine);
724
743
  const rand = () => engine.rng.next();
@@ -726,10 +745,12 @@ async function runOnce(json, seed, opts) {
726
745
  const reached = /* @__PURE__ */ new Set();
727
746
  const stepMs = 1e3 / 60;
728
747
  const deadline = (opts.seconds ?? 60) * 1e3;
729
- let outcome = "stuck";
748
+ let outcome = null;
730
749
  let at = start;
750
+ let wentAsFarAs = 0;
731
751
  while (session.timeMs < deadline) {
732
752
  at = player ? worldOf(player) : start;
753
+ wentAsFarAs = Math.max(wentAsFarAs, Math.hypot(at[0] - start[0], at[2] - start[2]));
733
754
  driver.step(stepMs / 1e3, at);
734
755
  session.step(stepMs);
735
756
  if (player) for (const [path, node] of targetNodes) {
@@ -746,15 +767,17 @@ async function runOnce(json, seed, opts) {
746
767
  outcome = "error";
747
768
  break;
748
769
  }
749
- if (player && (at[1] - floor) * down > 0) {
770
+ if (player && floor !== null && (at[1] - floor) * down > 0) {
750
771
  outcome = "fell";
751
772
  break;
752
773
  }
753
774
  }
754
775
  driver.release();
776
+ const WENT_NOWHERE = scene.dimension === "2d" ? STUCK_2D_PX : STUCK_3D_M;
777
+ outcome ??= wentAsFarAs > WENT_NOWHERE ? "unfinished" : "stuck";
755
778
  const heard = engine.audio.recent().map((e) => e.from);
756
779
  const shown = engine.effects.recent().map((e) => e.from);
757
- const bad = outcome !== "won";
780
+ const bad = outcome !== "won" && outcome !== "unfinished";
758
781
  const run = {
759
782
  seed,
760
783
  outcome,
@@ -857,12 +880,15 @@ function playtestText(report) {
857
880
  "error",
858
881
  "fell",
859
882
  "lost",
860
- "stuck"
883
+ "stuck",
884
+ "unfinished"
861
885
  ]) {
862
886
  const hits = runs.filter((r) => r.outcome === bad);
863
887
  if (hits.length === 0) continue;
864
888
  const where = bad === "fell" ? ` (last at y=${hits[0].endedAt[1].toFixed(1)})` : "";
865
- lines.push(` ✗ ${bad} in ${pct(hits.length, total)}${where}`);
889
+ const mark = bad === "unfinished" ? "·" : "✗";
890
+ const what = bad === "unfinished" ? "ran out of time still playing" : `${bad} in ${pct(hits.length, total)}`;
891
+ lines.push(bad === "unfinished" ? ` ${mark} ${pct(hits.length, total)} ${what}` : ` ${mark} ${what}${where}`);
866
892
  }
867
893
  const everReached = new Set(runs.flatMap((r) => r.reached));
868
894
  const missed = targets.filter((path) => !everReached.has(path));
@@ -1016,6 +1042,10 @@ function facingText(report) {
1016
1042
  const STEP_MS = 1e3 / 60;
1017
1043
  /** Drop height for the buffer probe — long enough to outlast a coyote window. */
1018
1044
  const DROP = 12;
1045
+ /** Metres a 3D probe is lifted/dropped from. */
1046
+ const UNIT_LIFT = 40;
1047
+ /** Pixels a 2D probe is lifted/dropped from — a 2D world is ~30x the numbers. */
1048
+ const PX_LIFT = 1200;
1019
1049
  function positionOf(node) {
1020
1050
  const p = node.position;
1021
1051
  return [
@@ -1062,11 +1092,14 @@ async function makeProbe(json, opts) {
1062
1092
  jump
1063
1093
  };
1064
1094
  }
1095
+ const is2d = session.scene.dimension === "2d";
1065
1096
  return {
1066
1097
  probe: {
1067
1098
  session,
1068
1099
  engine: session.engine,
1069
1100
  player,
1101
+ up: is2d ? -1 : 1,
1102
+ lift: is2d ? PX_LIFT : UNIT_LIFT,
1070
1103
  step: (frames = 1) => session.step(frames * STEP_MS),
1071
1104
  pos: () => positionOf(player),
1072
1105
  vel: () => velocityOf(player),
@@ -1159,7 +1192,7 @@ function jumpShape(probe, jump) {
1159
1192
  let airtime = null;
1160
1193
  for (let f = 2; f <= 600; f++) {
1161
1194
  probe.step();
1162
- const h = probe.pos()[1] - floor;
1195
+ const h = (probe.pos()[1] - floor) * probe.up;
1163
1196
  if (h > apex) {
1164
1197
  apex = h;
1165
1198
  toApex = f;
@@ -1169,7 +1202,8 @@ function jumpShape(probe, jump) {
1169
1202
  break;
1170
1203
  }
1171
1204
  }
1172
- if (apex <= .02) return {
1205
+ const noise = probe.up > 0 ? .02 : 2;
1206
+ if (apex <= noise) return {
1173
1207
  apex: null,
1174
1208
  toApexMs: null,
1175
1209
  airtimeMs: null
@@ -1197,7 +1231,7 @@ async function measureCoyote(json, opts, jump) {
1197
1231
  const p = probe.pos();
1198
1232
  probe.player.position = [
1199
1233
  p[0],
1200
- p[1] + 40,
1234
+ p[1] + probe.lift * probe.up,
1201
1235
  p[2]
1202
1236
  ];
1203
1237
  probe.step(delayFrames);
@@ -1206,7 +1240,7 @@ async function measureCoyote(json, opts, jump) {
1206
1240
  probe.step();
1207
1241
  const after = probe.vel()[1];
1208
1242
  probe.session.dispose();
1209
- return after - before > 1;
1243
+ return (after - before) * probe.up > 1;
1210
1244
  };
1211
1245
  if (!await fired(1)) return 0;
1212
1246
  let lo = 1;
@@ -1231,13 +1265,14 @@ async function measureBuffer(json, opts, jump) {
1231
1265
  if (!probe) return false;
1232
1266
  settle$1(probe);
1233
1267
  const floor = probe.pos()[1];
1268
+ const drop = (probe.up > 0 ? DROP : probe.lift / 3) * probe.up;
1234
1269
  probe.player.position = [
1235
1270
  probe.pos()[0],
1236
- floor + DROP,
1271
+ floor + drop,
1237
1272
  probe.pos()[2]
1238
1273
  ];
1239
1274
  let frames = 0;
1240
- while (probe.pos()[1] > floor + .05 && frames < 200) {
1275
+ while ((probe.pos()[1] - floor) * probe.up > .05 && frames < 200) {
1241
1276
  probe.step();
1242
1277
  frames += 1;
1243
1278
  }
@@ -1249,7 +1284,7 @@ async function measureBuffer(json, opts, jump) {
1249
1284
  settle$1(p2);
1250
1285
  p2.player.position = [
1251
1286
  p2.pos()[0],
1252
- floor + DROP,
1287
+ floor + drop,
1253
1288
  p2.pos()[2]
1254
1289
  ];
1255
1290
  p2.step(Math.max(0, fallFrames - earlyFrames));
@@ -1259,7 +1294,7 @@ async function measureBuffer(json, opts, jump) {
1259
1294
  let rose = false;
1260
1295
  for (let f = 0; f < 40; f++) {
1261
1296
  p2.step();
1262
- if (p2.vel()[1] > 1) rose = true;
1297
+ if (p2.vel()[1] * p2.up > 1) rose = true;
1263
1298
  }
1264
1299
  p2.session.dispose();
1265
1300
  return rose;
@@ -1753,10 +1788,10 @@ async function runScript(json, opts) {
1753
1788
  engine.setScene(scene);
1754
1789
  const physics = opts.physics ?? "auto";
1755
1790
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1756
- const { enablePhysics2D } = await import("./physics-2d-CfWAggJ1.js").then((n) => n.r);
1791
+ const { enablePhysics2D } = await import("./physics-2d-EqA-jddf.js").then((n) => n.r);
1757
1792
  await enablePhysics2D(engine);
1758
1793
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1759
- const { enablePhysics3D } = await import("./physics-3d-NzDwWPrF.js").then((n) => n.r);
1794
+ const { enablePhysics3D } = await import("./physics-3d-Dnz4fsXX.js").then((n) => n.r);
1760
1795
  await enablePhysics3D(engine);
1761
1796
  }
1762
1797
  const failures = [];
@@ -1869,10 +1904,10 @@ async function createPlaySession(json, opts = {}) {
1869
1904
  engine.setScene(scene);
1870
1905
  const physics = opts.physics ?? "auto";
1871
1906
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1872
- const { enablePhysics2D } = await import("./physics-2d-CfWAggJ1.js").then((n) => n.r);
1907
+ const { enablePhysics2D } = await import("./physics-2d-EqA-jddf.js").then((n) => n.r);
1873
1908
  await enablePhysics2D(engine);
1874
1909
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1875
- const { enablePhysics3D } = await import("./physics-3d-NzDwWPrF.js").then((n) => n.r);
1910
+ const { enablePhysics3D } = await import("./physics-3d-Dnz4fsXX.js").then((n) => n.r);
1876
1911
  await enablePhysics3D(engine);
1877
1912
  }
1878
1913
  const stepMs = 1e3 / (opts.fixedHz ?? 60);
package/dist/test.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { At as Node, Nt as LogEntry, P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-CyQoSu4n.js";
1
+ import { At as Node, Nt as LogEntry, P as Scene, b as Engine, n as BehaviorCtor } from "./behavior-l08AEbq9.js";
2
2
  import { c as JsonValue, i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
3
- import { t as LoadSceneOptions } from "./loader-DhI1jFW_.js";
4
- import { l as auditScene, o as IncantoError, r as ReplayJson } from "./replay-ePMz26jw.js";
5
- import { l as LocalGameServerOptions, n as SplitScreenPlayer } from "./split-screen-BsdOHbzP.js";
3
+ import { t as LoadSceneOptions } from "./loader-BbEMTuWg.js";
4
+ import { l as auditScene, o as IncantoError, r as ReplayJson } from "./replay-Dw6gMlYA.js";
5
+ import { l as LocalGameServerOptions, n as SplitScreenPlayer } from "./split-screen-B0baBwxI.js";
6
6
 
7
7
  //#region src/test/facing.d.ts
8
8
  interface FacingOptions {
@@ -193,7 +193,7 @@ declare function multiplayProblems(detail: MultiplayPlayer[], rooms: string[]):
193
193
  declare function multiplayText(report: MultiplayReport): string;
194
194
  //#endregion
195
195
  //#region src/test/playtest.d.ts
196
- type Outcome = "won" | "lost" | "fell" | "error" | "stuck";
196
+ type Outcome = "won" | "lost" | "fell" | "error" | "stuck" | "unfinished";
197
197
  interface PlaytestOptions {
198
198
  /** How many independent runs. Each gets its own seed. Default 20. */
199
199
  runs?: number;
package/dist/test.js CHANGED
@@ -1,3 +1,3 @@
1
- import { r as auditScene } from "./replay-t1pP0gQg.js";
2
- import { S as framingText, _ as failingReplays, a as registerAllNodes, b as playtestText, c as ladderText, d as multiplayText, f as playMultiplayer, g as facingText, h as facingReport, i as findFloatingProps, l as ladderVerdict, m as feelText, n as createPlaySession, o as runScript, p as feelReport, r as describeCapture, s as validateScene, t as captureScene, u as multiplayProblems, v as findPlayer, x as describeFraming, y as playtest } from "./test-Bu6SPNsV.js";
1
+ import { r as auditScene } from "./replay-s7I2GstT.js";
2
+ import { S as framingText, _ as failingReplays, a as registerAllNodes, b as playtestText, c as ladderText, d as multiplayText, f as playMultiplayer, g as facingText, h as facingReport, i as findFloatingProps, l as ladderVerdict, m as feelText, n as createPlaySession, o as runScript, p as feelReport, r as describeCapture, s as validateScene, t as captureScene, u as multiplayProblems, v as findPlayer, x as describeFraming, y as playtest } from "./test-ZBga8kQ9.js";
3
3
  export { auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, ladderText, ladderVerdict, multiplayProblems, multiplayText, playMultiplayer, playtest, playtestText, registerAllNodes, runScript, validateScene };