incanto 0.47.0 → 0.49.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 (55) hide show
  1. package/bin/incanto-frame.mjs +58 -1
  2. package/bin/incanto-logs.mjs +127 -0
  3. package/bin/incanto-model.mjs +13 -1
  4. package/bin/incanto-playtest.mjs +7 -0
  5. package/bin/incanto-verify.mjs +41 -2
  6. package/dist/2d.d.ts +21 -12
  7. package/dist/2d.js +57 -3
  8. package/dist/3d.d.ts +38 -1
  9. package/dist/3d.js +114 -4
  10. package/dist/{create-game-CniOiWzN.js → create-game-BgV6UbVA.js} +5 -5
  11. package/dist/{create-game-D16MVIPO.js → create-game-DFBjMetZ.js} +83 -6
  12. package/dist/{duplicate-CRtihGmC.js → duplicate-CI9WF_bg.js} +1 -1
  13. package/dist/{environment-presets-D1b0ydTS.js → environment-presets-CZOH5TY5.js} +15 -20
  14. package/dist/{gameplay-BQOeAid6.js → gameplay-02Btmmjn.js} +80 -9
  15. package/dist/gameplay.d.ts +17 -0
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +139 -1
  18. package/dist/index.js +8 -7
  19. package/dist/{loader-r49nDwB4.js → loader-DwazzlQb.js} +36 -6
  20. package/dist/log-report-lxrQY9cH.js +0 -0
  21. package/dist/net.js +3 -3
  22. package/dist/{physics-2d-BmgXBNDB.js → physics-2d-vyCBfACH.js} +3 -3
  23. package/dist/{physics-3d-CSoGjM8P.js → physics-3d-DpRqw8Mz.js} +4 -4
  24. package/dist/react.js +1 -1
  25. package/dist/{register-R2JTnIMw.js → register-BNPZYJmd.js} +23 -22
  26. package/dist/{register-D651it1J.js → register-CB11yp21.js} +2 -2
  27. package/dist/{register-BSXV8T9F.js → register-uvaZj1KX.js} +62 -2
  28. package/dist/{replay-DilbZgQI.js → replay-C0XJIsO7.js} +1 -1
  29. package/dist/sprite-animation-CMr6f1K2.d.ts +44 -0
  30. package/dist/{particle-sim-Bw7hB93B.js → sprite-animation-D_p28jwU.js} +63 -1
  31. package/dist/{src-Ca3oV1fe.js → src-DF4gCsqO.js} +1 -1
  32. package/dist/{test-E4-otKqK.js → test-DRna_BQU.js} +210 -30
  33. package/dist/test.d.ts +38 -6
  34. package/dist/test.js +2 -2
  35. package/dist/vite.js +41 -4
  36. package/editor/assets/{agent8-_007gPF8.js → agent8-DCW4TgDt.js} +1 -1
  37. package/editor/assets/{debug-0DI_MJaq.js → debug-RC6qts6S.js} +1 -1
  38. package/editor/assets/{index-B-6eYZEi.js → index-5dEIhvsf.js} +92 -92
  39. package/editor/index.html +1 -1
  40. package/package.json +3 -2
  41. package/schemas/scene.schema.json +10 -3
  42. package/skills/incanto-3d-character.md +13 -2
  43. package/skills/incanto-3d-models.md +40 -0
  44. package/skills/incanto-assets.md +15 -0
  45. package/skills/incanto-building-2d-games.md +57 -5
  46. package/skills/incanto-building-3d-games.md +7 -0
  47. package/skills/incanto-gameplay-behaviors.md +18 -1
  48. package/skills/incanto-hud.md +39 -0
  49. package/skills/incanto-node-reference.md +5 -3
  50. package/skills/incanto-physics-and-input.md +1 -1
  51. package/skills/incanto-playtesting.md +9 -2
  52. package/skills/incanto-verifying-your-game.md +67 -0
  53. package/templates-app/beacon-isle-3d/package.json +1 -1
  54. package/templates-app/tps-3d/package.json +1 -1
  55. package/templates-app/village-quest-3d/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { t as IncantoError } from "./errors-BpWbnbb_.js";
1
2
  import { n as jsonEquals } from "./json-BLk7H2Qa.js";
2
3
  //#region src/core/particle-presets.ts
3
4
  const PARTICLE_PRESETS = {
@@ -264,4 +265,65 @@ var ParticleSim = class {
264
265
  }
265
266
  };
266
267
  //#endregion
267
- export { applyParticlePreset as i, PARTICLE_PRESETS as n, PARTICLE_PRESET_NAMES as r, ParticleSim as t };
268
+ //#region src/core/sprite-animation.ts
269
+ /**
270
+ * The animation MAP a spritesheet node is driven by — shared by 2D and 3D.
271
+ *
272
+ * Core, not an adapter: no three.js, and the two sprite nodes had grown
273
+ * byte-identical copies of the frame reader.
274
+ */
275
+ /**
276
+ * Follow aliases to the clip that actually plays.
277
+ *
278
+ * Chains are allowed (`fall → jump → idle`) because they are how a fallback
279
+ * ladder reads, and a cycle is a hard error rather than a hang.
280
+ */
281
+ function resolveAnimation(animations, name, owner) {
282
+ const seen = [];
283
+ let key = name;
284
+ for (;;) {
285
+ const entry = animations[key];
286
+ if (entry === void 0) {
287
+ const via = seen.length > 0 ? ` (via ${seen.join(" → ")})` : "";
288
+ throw new IncantoError("UNKNOWN_ANIMATION", `No animation '${key}' on '${owner}'${via}. Available: [${Object.keys(animations).join(", ")}].`);
289
+ }
290
+ if (typeof entry !== "string") return {
291
+ name: key,
292
+ def: entry
293
+ };
294
+ if (seen.includes(key)) throw new IncantoError("UNKNOWN_ANIMATION", `Animation aliases on '${owner}' point in a circle: ${[...seen, key].join(" → ")}.`);
295
+ seen.push(key);
296
+ key = entry;
297
+ }
298
+ }
299
+ /**
300
+ * Every alias resolves — checked once, at ready, rather than on the frame the
301
+ * character first jumps.
302
+ *
303
+ * A cosmetic map that only fails the moment a state is first entered is the
304
+ * shape of bug that reaches players: the game loads, plays, and dies on a
305
+ * ledge twenty minutes in.
306
+ */
307
+ function validateAnimationAliases(animations, owner) {
308
+ for (const [name, entry] of Object.entries(animations)) if (typeof entry === "string") resolveAnimation(animations, name, owner);
309
+ }
310
+ /**
311
+ * `[start, end]` is a RANGE; anything else is an explicit list.
312
+ *
313
+ * Two ascending numbers are overwhelmingly a range in every sheet format, and
314
+ * a two-frame flip-book is spelled `[3, 3, 4, 4]` or a descending pair.
315
+ */
316
+ function resolveFrames(frames, name) {
317
+ if (!Array.isArray(frames) || frames.length === 0) throw new IncantoError("BAD_FORMAT", `Animation '${name}': "frames" must be a non-empty array.`);
318
+ if (frames.length === 2) {
319
+ const [start, end] = frames;
320
+ if (end >= start) {
321
+ const out = [];
322
+ for (let f = start; f <= end; f++) out.push(f);
323
+ return out;
324
+ }
325
+ }
326
+ return [...frames];
327
+ }
328
+ //#endregion
329
+ export { PARTICLE_PRESETS as a, ParticleSim as i, resolveFrames as n, PARTICLE_PRESET_NAMES as o, validateAnimationAliases as r, applyParticlePreset as s, resolveAnimation as t };
@@ -161,6 +161,6 @@ function newUid() {
161
161
  //#endregion
162
162
  //#region src/index.ts
163
163
  /** Engine version. Kept in sync with package.json by the release pipeline. */
164
- const VERSION = "0.47.0";
164
+ const VERSION = "0.49.0";
165
165
  //#endregion
166
166
  export { findPath as a, preloadUrls as i, newUid as n, gridFromRows as o, assetUrls as r, VERSION as t };
@@ -1,13 +1,13 @@
1
- import { n as loadScene, w as registerBehavior } from "./loader-r49nDwB4.js";
2
- import { h as Engine } from "./register-BSXV8T9F.js";
1
+ import { n as loadScene, s as resolveViewport, w as registerBehavior } from "./loader-DwazzlQb.js";
2
+ import { h as Engine } from "./register-uvaZj1KX.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { n as startRecording } from "./replay-DilbZgQI.js";
4
+ import { n as startRecording } from "./replay-C0XJIsO7.js";
5
5
  import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
6
6
  import { i as getNodeSchema, s as mergeStaticProps } from "./registry-IyWCGe4q.js";
7
- import { n as registerGameplayBehaviors } from "./gameplay-BQOeAid6.js";
8
- import { t as registerNodes2D } from "./register-R2JTnIMw.js";
9
- import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-D1b0ydTS.js";
10
- import { t as registerNodesNet } from "./register-D651it1J.js";
7
+ import { n as registerGameplayBehaviors } from "./gameplay-02Btmmjn.js";
8
+ import { t as registerNodes2D } from "./register-BNPZYJmd.js";
9
+ import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-CZOH5TY5.js";
10
+ import { t as registerNodesNet } from "./register-CB11yp21.js";
11
11
  import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
12
12
  //#region src/test/framing.ts
13
13
  /**
@@ -38,6 +38,13 @@ const LIGHT_TYPES = new Set([
38
38
  "OmniLight3D",
39
39
  "SpotLight3D"
40
40
  ]);
41
+ /** Screen-space subtrees: they ignore the camera, so framing has nothing to say. */
42
+ const SCREEN_SPACE = new Set(["UILayer", "HudLayer"]);
43
+ /** How far two colliders must actually interpenetrate to be a finding. */
44
+ const TOUCH_EPS_2D = 1;
45
+ const TOUCH_EPS_3D = .01;
46
+ /** What a 2D scene shows when it declares neither a viewport nor a canvas. */
47
+ const DEFAULT_VIEW_2D = [960, 540];
41
48
  /**
42
49
  * World transform for every node, composed from the tree.
43
50
  *
@@ -50,7 +57,11 @@ function place(scene) {
50
57
  const type = node.constructor.typeName ?? "Node";
51
58
  const n = node;
52
59
  const local = new Matrix4();
53
- if (Array.isArray(n.position)) local.compose(new Vector3(n.position[0] ?? 0, n.position[1] ?? 0, n.position[2] ?? 0), new Quaternion().setFromEuler(new Euler((n.rotation?.[0] ?? 0) * DEG, (n.rotation?.[1] ?? 0) * DEG, (n.rotation?.[2] ?? 0) * DEG, "XYZ")), new Vector3(n.scale?.[0] ?? 1, n.scale?.[1] ?? 1, n.scale?.[2] ?? 1));
60
+ if (Array.isArray(n.position)) {
61
+ const r = n.rotation;
62
+ const euler = typeof r === "number" ? new Euler(0, 0, r * DEG) : new Euler((r?.[0] ?? 0) * DEG, (r?.[1] ?? 0) * DEG, (r?.[2] ?? 0) * DEG, "XYZ");
63
+ local.compose(new Vector3(n.position[0] ?? 0, n.position[1] ?? 0, n.position[2] ?? 0), new Quaternion().setFromEuler(euler), new Vector3(n.scale?.[0] ?? 1, n.scale?.[1] ?? 1, n.scale?.[2] ?? 1));
64
+ }
54
65
  const matrix = new Matrix4().multiplyMatrices(parent, local);
55
66
  out.push({
56
67
  node,
@@ -58,6 +69,7 @@ function place(scene) {
58
69
  matrix,
59
70
  position: new Vector3().setFromMatrixPosition(matrix)
60
71
  });
72
+ if (SCREEN_SPACE.has(type)) return;
61
73
  for (const child of node.children) walk(child, matrix);
62
74
  };
63
75
  walk(scene.root, new Matrix4());
@@ -74,6 +86,54 @@ function draws(node) {
74
86
  return proto._createObject3D !== base._createObject3D;
75
87
  }
76
88
  /**
89
+ * A 2D node draws something if its class puts pixels on its backing object.
90
+ *
91
+ * NOT `_createObject2D` — the 3D test's counterpart, and wrong here. 2D
92
+ * drawables all inherit the plain container and hang a quad off it in
93
+ * `_syncObject2D`, so the create hook is identical for a sprite and for a bare
94
+ * `Node2D`, and testing it reports a scene full of sprites as drawing nothing.
95
+ */
96
+ function draws2D(node) {
97
+ const proto = Object.getPrototypeOf(node);
98
+ let base = proto;
99
+ while (base && base.constructor?.typeName !== "Node2D") {
100
+ base = Object.getPrototypeOf(base);
101
+ if (!base) return false;
102
+ }
103
+ return proto._syncObject2D !== base._syncObject2D;
104
+ }
105
+ /**
106
+ * A 2D node's half-size in pixels, and where its box hangs off the origin.
107
+ *
108
+ * Most 2D nodes are centred on their origin, but a `TileMap2D` hangs cell (0,0)
109
+ * by its TOP-LEFT corner — a whole level reported as a point at its corner is
110
+ * how you conclude the map is off screen when you are standing on it.
111
+ */
112
+ function box2D(node) {
113
+ if ((node.constructor.typeName ?? "") === "TileMap2D") {
114
+ const map = node;
115
+ const rows = Array.isArray(map.cells) ? map.cells : [];
116
+ const cols = rows.reduce((n, r) => Math.max(n, String(r).length), 0);
117
+ const ts = map.tileSize ?? 32;
118
+ if (rows.length === 0 || cols === 0) return null;
119
+ const half = [cols * ts / 2, rows.length * ts / 2];
120
+ return {
121
+ half,
122
+ centre: half
123
+ };
124
+ }
125
+ const size = node.size;
126
+ if (Array.isArray(size) && typeof size[0] === "number" && typeof size[1] === "number") return {
127
+ half: [Math.abs(size[0]) / 2, Math.abs(size[1]) / 2],
128
+ centre: [0, 0]
129
+ };
130
+ const half = halfExtents(node);
131
+ return half ? {
132
+ half: [half.x, half.y],
133
+ centre: [0, 0]
134
+ } : null;
135
+ }
136
+ /**
77
137
  * How big the node is on screen, from whatever it declares.
78
138
  *
79
139
  * `size` first (a MeshInstance3D's box, a Terrain3D's ground), then a collider.
@@ -118,10 +178,110 @@ function halfExtents(node) {
118
178
  const r = collider.radius ?? .5;
119
179
  return new Vector3(r, (collider.height ?? 1) / 2 + r, r);
120
180
  }
181
+ if (shape === "rect") {
182
+ const size = collider.size ?? [1, 1];
183
+ return new Vector3((size[0] ?? 1) / 2, (size[1] ?? 1) / 2, .5);
184
+ }
185
+ if (shape === "circle") {
186
+ const r = collider.radius ?? .5;
187
+ return new Vector3(r, r, .5);
188
+ }
121
189
  return null;
122
190
  }
191
+ /**
192
+ * The same question in 2D: an orthographic window of design pixels, centred on
193
+ * the Camera2D and CLAMPED by its limits.
194
+ *
195
+ * The clamp is not a detail — it is the whole point of `limits`, and a report
196
+ * that skips it describes a view the renderer never draws. A following camera
197
+ * sits at the player's y, which in a 544 px world is hundreds of pixels past
198
+ * anything a 540 px window can show.
199
+ *
200
+ * With no camera at all a 2D scene still draws: the documented default view is
201
+ * `(0,0)`–`(w,h)`, so "no camera" is not "nothing is framed" the way it is in 3D.
202
+ */
203
+ function describeFraming2D(scene, placed, opts) {
204
+ const design = resolveViewport(scene.viewport)?.design;
205
+ const [vw, vh] = opts.viewport ?? design ?? DEFAULT_VIEW_2D;
206
+ const cameras = placed.filter((p) => p.type === "Camera2D");
207
+ const chosen = cameras.find((p) => p.node.current === true) ?? cameras[0] ?? null;
208
+ let camera = null;
209
+ let view = {
210
+ minX: 0,
211
+ minY: 0,
212
+ maxX: vw,
213
+ maxY: vh
214
+ };
215
+ if (chosen) {
216
+ const cam = chosen.node;
217
+ const zoom = Math.max(.01, cam.zoom ?? 1);
218
+ const halfW = vw / (2 * zoom);
219
+ const halfH = vh / (2 * zoom);
220
+ let cx = chosen.position.x;
221
+ let cy = chosen.position.y;
222
+ const limits = cam.limits ?? [];
223
+ if (limits.length === 4) {
224
+ const [minX, minY, maxX, maxY] = limits;
225
+ cx = clampCentred(cx, minX + halfW, maxX - halfW);
226
+ cy = clampCentred(cy, minY + halfH, maxY - halfH);
227
+ }
228
+ view = {
229
+ minX: cx - halfW,
230
+ minY: cy - halfH,
231
+ maxX: cx + halfW,
232
+ maxY: cy + halfH
233
+ };
234
+ camera = {
235
+ path: chosen.node.getPath(),
236
+ at: [
237
+ round(cx),
238
+ round(cy),
239
+ 0
240
+ ],
241
+ view: [vw, vh],
242
+ zoom
243
+ };
244
+ }
245
+ const wanted = opts.types ? new Set(opts.types) : null;
246
+ const centre = [(view.minX + view.maxX) / 2, (view.minY + view.maxY) / 2];
247
+ const entries = [];
248
+ for (const p of placed) {
249
+ if (p.type === "Camera2D") continue;
250
+ if (!(wanted ? wanted.has(p.type) : draws2D(p.node))) continue;
251
+ const b = box2D(p.node);
252
+ const cx = p.position.x + (b?.centre[0] ?? 0);
253
+ const cy = p.position.y + (b?.centre[1] ?? 0);
254
+ const hx = b?.half[0] ?? 0;
255
+ const hy = b?.half[1] ?? 0;
256
+ const onScreen = cx + hx >= view.minX && cx - hx <= view.maxX && cy + hy >= view.minY && cy - hy <= view.maxY;
257
+ entries.push({
258
+ path: p.node.getPath(),
259
+ type: p.type,
260
+ at: [
261
+ round(p.position.x),
262
+ round(p.position.y),
263
+ 0
264
+ ],
265
+ where: onScreen ? "onScreen" : "offscreen",
266
+ screen: [round((cx - centre[0]) / ((view.maxX - view.minX) / 2)), round((cy - centre[1]) / ((view.maxY - view.minY) / 2))],
267
+ distance: round(Math.hypot(cx - centre[0], cy - centre[1]))
268
+ });
269
+ }
270
+ return {
271
+ dimension: "2d",
272
+ camera,
273
+ entries,
274
+ lights: [],
275
+ overlaps: findOverlaps(placed, TOUCH_EPS_2D)
276
+ };
277
+ }
278
+ function clampCentred(v, lo, hi) {
279
+ if (lo > hi) return (lo + hi) / 2;
280
+ return Math.min(hi, Math.max(lo, v));
281
+ }
123
282
  function describeFraming(scene, opts = {}) {
124
283
  const placed = place(scene);
284
+ if (scene.dimension === "2d") return describeFraming2D(scene, placed, opts);
125
285
  const cameras = placed.filter((p) => CAMERA_TYPES.has(p.type));
126
286
  const chosen = cameras.find((p) => p.node.current === true) ?? cameras[0] ?? null;
127
287
  const lights = [];
@@ -185,10 +345,11 @@ function describeFraming(scene, opts = {}) {
185
345
  }
186
346
  }
187
347
  return {
348
+ dimension: "3d",
188
349
  camera,
189
350
  entries,
190
351
  lights,
191
- overlaps: findOverlaps(placed)
352
+ overlaps: findOverlaps(placed, TOUCH_EPS_3D)
192
353
  };
193
354
  }
194
355
  function cornersOnScreen(box, cam) {
@@ -207,14 +368,16 @@ function cornersOnScreen(box, cam) {
207
368
  * no loaded model — and a prop buried inside a platform is the thing you were
208
369
  * trying to see anyway.
209
370
  */
210
- function findOverlaps(placed) {
371
+ function findOverlaps(placed, epsilon) {
211
372
  const boxes = [];
212
373
  for (const p of placed) {
213
374
  const half = halfExtents(p.node);
214
375
  if (!half) continue;
376
+ const shrunk = half.clone().multiplyScalar(2).addScalar(-2 * epsilon);
215
377
  boxes.push({
216
378
  path: p.node.getPath(),
217
- box: new Box3().setFromCenterAndSize(p.position, half.clone().multiplyScalar(2))
379
+ internal: p.node.name.startsWith("__"),
380
+ box: new Box3().setFromCenterAndSize(p.position, shrunk.max(new Vector3(0, 0, 0)))
218
381
  });
219
382
  }
220
383
  const out = [];
@@ -223,6 +386,7 @@ function findOverlaps(placed) {
223
386
  const b = boxes[j];
224
387
  if (!a || !b) continue;
225
388
  if (a.path.startsWith(`${b.path}/`) || b.path.startsWith(`${a.path}/`)) continue;
389
+ if (a.internal && b.internal) continue;
226
390
  if (a.box.intersectsBox(b.box)) out.push({
227
391
  a: a.path,
228
392
  b: b.path
@@ -236,26 +400,41 @@ function round(n) {
236
400
  /** The report as something to read in a terminal. */
237
401
  function framingText(report) {
238
402
  const lines = [];
239
- if (!report.camera) lines.push("camera: NONE — this scene frames nothing (no Camera3D)");
240
- else {
241
- const c = report.camera;
242
- lines.push(`camera ${c.path} at [${c.at.map(round).join(", ")}] looking [${c.forward.join(", ")}] fov ${c.fovDeg}`);
243
- }
244
- lines.push(report.lights.length ? `lit by: ${report.lights.join(", ")}` : "lit by: NOTHING this scene renders black");
403
+ const is2d = report.dimension === "2d";
404
+ const unit = is2d ? "px" : "m";
405
+ const c = report.camera;
406
+ if (c && is2d) {
407
+ const [vw = 0, vh = 0] = c.view ?? [];
408
+ const zoom = c.zoom === 1 ? "" : ` zoom ${c.zoom}`;
409
+ lines.push(`camera ${c.path} centred [${c.at[0]}, ${c.at[1]}] showing ${vw}×${vh}px${zoom}`);
410
+ } else if (c) lines.push(`camera ${c.path} at [${c.at.map(round).join(", ")}] looking [${(c.forward ?? []).join(", ")}] fov ${c.fovDeg}`);
411
+ else if (is2d) lines.push("camera: NONE — the view is (0,0)–(design), which may be intentional");
412
+ else lines.push("camera: NONE — this scene frames nothing (no Camera3D)");
413
+ if (!is2d) lines.push(report.lights.length ? `lit by: ${report.lights.join(", ")}` : "lit by: NOTHING — this scene renders black");
245
414
  const on = report.entries.filter((e) => e.where === "onScreen");
246
415
  const off = report.entries.filter((e) => e.where === "offscreen");
247
416
  const behind = report.entries.filter((e) => e.where === "behind");
248
- lines.push(`${on.length} on screen, ${off.length} off screen, ${behind.length} behind the camera`);
417
+ lines.push(is2d ? `${on.length} in view, ${off.length} outside it` : `${on.length} on screen, ${off.length} off screen, ${behind.length} behind the camera`);
249
418
  for (const e of report.entries) {
250
- const at = `[${e.at.map(round).join(", ")}]`;
419
+ const at = `[${(is2d ? e.at.slice(0, 2) : e.at).map(round).join(", ")}]`;
251
420
  const screen = e.screen ? ` screen [${e.screen.join(", ")}]` : "";
252
- lines.push(` ${e.where.padEnd(9)} ${e.path} (${e.type}) ${at}${screen} ${e.distance}m`);
421
+ lines.push(` ${e.where.padEnd(9)} ${e.path} (${e.type}) ${at}${screen} ${e.distance}${unit}`);
253
422
  }
254
423
  for (const o of report.overlaps) lines.push(` overlap ${o.a} ∩ ${o.b}`);
255
424
  return lines.join("\n");
256
425
  }
257
426
  //#endregion
258
427
  //#region src/test/playtest.ts
428
+ /**
429
+ * Out of the world, in pixels — about two screens under the spawn.
430
+ *
431
+ * Generous on purpose: a player who has actually left the level is falling
432
+ * forever, so a far line costs a few frames and never mistakes a long drop
433
+ * down a shaft for one.
434
+ */
435
+ const FALL_2D_PX = 1e3;
436
+ /** "Reached it", in pixels — one 32 px tile, next to a ~34 px character. */
437
+ const REACH_2D_PX = 32;
259
438
  const CONTROLLERS = new Set(["CharacterController2D", "CharacterController3D"]);
260
439
  /**
261
440
  * Who the driver is playing as.
@@ -499,8 +678,9 @@ async function runOnce(json, seed, opts) {
499
678
  0,
500
679
  0
501
680
  ];
502
- const floor = opts.fallBelow ?? start[1] - 50;
503
- const radius = opts.reachRadius ?? 2;
681
+ const down = scene.dimension === "2d" ? 1 : -1;
682
+ const floor = opts.fallBelow ?? start[1] + down * (down > 0 ? FALL_2D_PX : 50);
683
+ const radius = opts.reachRadius ?? (down > 0 ? REACH_2D_PX : 2);
504
684
  const recorder = startRecording(engine);
505
685
  const rand = () => engine.rng.next();
506
686
  const driver = new Driver(engine, rand);
@@ -527,7 +707,7 @@ async function runOnce(json, seed, opts) {
527
707
  outcome = "error";
528
708
  break;
529
709
  }
530
- if (player && at[1] < floor) {
710
+ if (player && (at[1] - floor) * down > 0) {
531
711
  outcome = "fell";
532
712
  break;
533
713
  }
@@ -584,7 +764,8 @@ async function playtest(json, opts = {}) {
584
764
  signals: [...new Set(signals)],
585
765
  actions,
586
766
  inertActions: [],
587
- declaresWin
767
+ declaresWin,
768
+ seconds: opts.seconds ?? 60
588
769
  };
589
770
  }
590
771
  function pct(n, total) {
@@ -605,8 +786,7 @@ function playtestText(report) {
605
786
  const { runs, targets, signals } = report;
606
787
  const total = runs.length;
607
788
  const lines = [];
608
- const secs = Math.round((runs[0]?.timeMs ?? 0) / 1e3);
609
- lines.push(`${total} runs × up to ${secs}s`);
789
+ lines.push(`${total} runs × up to ${report.seconds}s`);
610
790
  lines.push("");
611
791
  const won = runs.filter((r) => r.outcome === "won");
612
792
  if (won.length > 0) lines.push(` ✓ reached "won" in ${pct(won.length, total)} median ${Math.round(median(won.map((r) => r.timeMs)) / 1e3)}s`);
@@ -1414,10 +1594,10 @@ async function runScript(json, opts) {
1414
1594
  engine.setScene(scene);
1415
1595
  const physics = opts.physics ?? "auto";
1416
1596
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1417
- const { enablePhysics2D } = await import("./physics-2d-BmgXBNDB.js").then((n) => n.r);
1597
+ const { enablePhysics2D } = await import("./physics-2d-vyCBfACH.js").then((n) => n.r);
1418
1598
  await enablePhysics2D(engine);
1419
1599
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1420
- const { enablePhysics3D } = await import("./physics-3d-CSoGjM8P.js").then((n) => n.r);
1600
+ const { enablePhysics3D } = await import("./physics-3d-DpRqw8Mz.js").then((n) => n.r);
1421
1601
  await enablePhysics3D(engine);
1422
1602
  }
1423
1603
  const failures = [];
@@ -1530,10 +1710,10 @@ async function createPlaySession(json, opts = {}) {
1530
1710
  engine.setScene(scene);
1531
1711
  const physics = opts.physics ?? "auto";
1532
1712
  if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
1533
- const { enablePhysics2D } = await import("./physics-2d-BmgXBNDB.js").then((n) => n.r);
1713
+ const { enablePhysics2D } = await import("./physics-2d-vyCBfACH.js").then((n) => n.r);
1534
1714
  await enablePhysics2D(engine);
1535
1715
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
1536
- const { enablePhysics3D } = await import("./physics-3d-CSoGjM8P.js").then((n) => n.r);
1716
+ const { enablePhysics3D } = await import("./physics-3d-DpRqw8Mz.js").then((n) => n.r);
1537
1717
  await enablePhysics3D(engine);
1538
1718
  }
1539
1719
  const stepMs = 1e3 / (opts.fixedHz ?? 60);
package/dist/test.d.ts CHANGED
@@ -52,12 +52,23 @@ interface FramingEntry {
52
52
  distance: number;
53
53
  }
54
54
  interface FramingReport {
55
- /** Null when the scene declares no 3D camera — nothing frames anything. */
55
+ /**
56
+ * Which report this is. The question is the same — can the camera see it —
57
+ * but the machinery and the UNITS are not: 3D projects a perspective frustum
58
+ * and measures metres, 2D clips an orthographic window and measures pixels.
59
+ */
60
+ dimension: "2d" | "3d";
61
+ /**
62
+ * 3D: null when the scene declares no camera — nothing frames anything.
63
+ * 2D: null means the documented default view, `(0,0)`–`(design)`.
64
+ */
56
65
  camera: {
57
66
  path: string;
58
- at: [number, number, number]; /** Unit vector the camera points along. */
59
- forward: [number, number, number];
60
- fovDeg: number;
67
+ at: [number, number, number]; /** 3D: the unit vector it points along. Absent in 2D. */
68
+ forward?: [number, number, number]; /** 3D only. */
69
+ fovDeg?: number; /** 2D only: world pixels visible, after `zoom`. */
70
+ view?: [number, number]; /** 2D only. */
71
+ zoom?: number;
61
72
  } | null;
62
73
  entries: FramingEntry[];
63
74
  /**
@@ -76,6 +87,11 @@ interface FramingOptions {
76
87
  aspect?: number;
77
88
  /** Report only these node types (default: everything that draws). */
78
89
  types?: readonly string[];
90
+ /**
91
+ * 2D only: the window in world pixels. Defaults to the scene's
92
+ * `viewport.design`, then 960×540 — headless there is no canvas to ask.
93
+ */
94
+ viewport?: readonly [number, number];
79
95
  }
80
96
  declare function describeFraming(scene: Scene, opts?: FramingOptions): FramingReport;
81
97
  /** The report as something to read in a terminal. */
@@ -133,9 +149,16 @@ interface PlaytestOptions {
133
149
  behaviors?: Record<string, BehaviorCtor>;
134
150
  stubMissingBehaviors?: boolean;
135
151
  resolveScene?: LoadSceneOptions["resolveScene"];
136
- /** Y below which the player has left the world. Default: 50 under the start. */
152
+ /**
153
+ * The y past which the player has left the world, in the SCENE's own
154
+ * direction: 3D counts down (−y), 2D counts down the screen (+y).
155
+ * Default: 50 m under the start in 3D, 1000 px under it in 2D.
156
+ */
137
157
  fallBelow?: number;
138
- /** How close counts as "reached", in world units. Default 2. */
158
+ /**
159
+ * How close counts as "reached", in the scene's own units. Default 2 (metres)
160
+ * in 3D, 32 (pixels — one tile) in 2D.
161
+ */
139
162
  reachRadius?: number;
140
163
  }
141
164
  interface PlaytestRun {
@@ -174,6 +197,15 @@ interface PlaytestReport {
174
197
  * author looking for a bug that was never there.
175
198
  */
176
199
  declaresWin: boolean;
200
+ /**
201
+ * The per-run clock the caller asked for, in simulated seconds.
202
+ *
203
+ * The header used to read this off the FIRST run's length, which is the one
204
+ * number guaranteed to be wrong when something ends runs early: a 60-second
205
+ * playtest whose every run died at a second announced itself as "20 runs ×
206
+ * up to 1s", hiding the very fact it was reporting.
207
+ */
208
+ seconds: number;
177
209
  }
178
210
  /**
179
211
  * Who the driver is playing as.
package/dist/test.js CHANGED
@@ -1,3 +1,3 @@
1
- import { r as auditScene } from "./replay-DilbZgQI.js";
2
- import { _ as playtestText, a as registerAllNodes, c as ladderText, d as feelText, f as facingReport, g as playtest, h as findPlayer, i as findFloatingProps, l as ladderVerdict, m as failingReplays, n as createPlaySession, o as runScript, p as facingText, r as describeCapture, s as validateScene, t as captureScene, u as feelReport, v as describeFraming, y as framingText } from "./test-E4-otKqK.js";
1
+ import { r as auditScene } from "./replay-C0XJIsO7.js";
2
+ import { _ as playtestText, a as registerAllNodes, c as ladderText, d as feelText, f as facingReport, g as playtest, h as findPlayer, i as findFloatingProps, l as ladderVerdict, m as failingReplays, n as createPlaySession, o as runScript, p as facingText, r as describeCapture, s as validateScene, t as captureScene, u as feelReport, v as describeFraming, y as framingText } from "./test-DRna_BQU.js";
3
3
  export { auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, ladderText, ladderVerdict, playtest, playtestText, registerAllNodes, runScript, validateScene };
package/dist/vite.js CHANGED
@@ -1,6 +1,6 @@
1
- import { t as VERSION } from "./src-Ca3oV1fe.js";
1
+ import { t as VERSION } from "./src-DF4gCsqO.js";
2
2
  import { n as diffSignatures } from "./frame-report-njybhZon.js";
3
- import { s as validateScene } from "./test-E4-otKqK.js";
3
+ import { s as validateScene } from "./test-DRna_BQU.js";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
6
6
  //#region src/vite/frame-endpoint.ts
@@ -60,6 +60,7 @@ function serveFrameEndpoints(host, version, opts = {}) {
60
60
  for (const c of channels) {
61
61
  c.on("incanto:frame-report", onReport);
62
62
  c.on("incanto:frame-ack", onAck);
63
+ c.on("incanto:logs-report", onReport);
63
64
  }
64
65
  host.middlewares.use("/__incanto/ping", (_req, res) => {
65
66
  json$1(res, 200, {
@@ -67,6 +68,7 @@ function serveFrameEndpoints(host, version, opts = {}) {
67
68
  frame: Boolean(channel)
68
69
  });
69
70
  });
71
+ serveLogs(host, channels, waiting, () => nextId++, timeoutMs);
70
72
  host.middlewares.use("/__incanto/frame", (req, res) => {
71
73
  if (!channel) {
72
74
  json$1(res, 503, { error: "no-hmr-channel" });
@@ -78,11 +80,13 @@ function serveFrameEndpoints(host, version, opts = {}) {
78
80
  const threshold = Number(params.get("threshold"));
79
81
  const grid = parseGrid(params.get("grid"));
80
82
  const image = Math.max(0, Math.min(2048, Number(params.get("image")) || 0));
83
+ const drive = params.get("drive") ?? "";
84
+ const driveMs = Math.max(0, Math.min(12e4, Number(params.get("driveMs")) || 0));
81
85
  const id = nextId++;
82
86
  const timer = setTimeout(() => {
83
87
  waiting.delete(id);
84
88
  json$1(res, 504, { error: acked.delete(id) ? "page-not-drawing" : "no-page-connected" });
85
- }, timeoutMs);
89
+ }, timeoutMs + (driveMs > 0 ? driveMs * 2 + 2e3 : 0));
86
90
  waiting.set(id, (value) => {
87
91
  clearTimeout(timer);
88
92
  acked.delete(id);
@@ -113,11 +117,44 @@ function serveFrameEndpoints(host, version, opts = {}) {
113
117
  data: {
114
118
  id,
115
119
  grid,
116
- image
120
+ image,
121
+ ...drive ? { drive } : {}
117
122
  }
118
123
  });
119
124
  });
120
125
  }
126
+ /**
127
+ * `/__incanto/logs` — what the game is SAYING, not what it looks like.
128
+ *
129
+ * Same round trip as a frame and a different question. It needs no render, so
130
+ * unlike a frame it answers from a tab the browser has stopped drawing.
131
+ */
132
+ function serveLogs(host, channels, waiting, nextId, timeoutMs) {
133
+ host.middlewares.use("/__incanto/logs", (_req, res) => {
134
+ if (channels.length === 0) {
135
+ json$1(res, 503, { error: "no-hmr-channel" });
136
+ return;
137
+ }
138
+ const id = nextId();
139
+ const timer = setTimeout(() => {
140
+ waiting.delete(id);
141
+ json$1(res, 504, { error: "no-page-connected" });
142
+ }, timeoutMs);
143
+ waiting.set(id, (value) => {
144
+ clearTimeout(timer);
145
+ if (!value.ok) {
146
+ json$1(res, 500, { error: "report-failed" });
147
+ return;
148
+ }
149
+ json$1(res, 200, { report: value.report });
150
+ });
151
+ for (const c of channels) c.send({
152
+ type: "custom",
153
+ event: "incanto:logs-request",
154
+ data: { id }
155
+ });
156
+ });
157
+ }
121
158
  function query(req) {
122
159
  const url = req?.originalUrl ?? req?.url ?? "";
123
160
  const q = url.indexOf("?");
@@ -1 +1 @@
1
- import{n as e}from"./index-B-6eYZEi.js";async function t(t){return new n((await e(()=>import(`./GameServer-C56iOUgF.js`),[],import.meta.url)).GameServer,t)}var n=class{raw;active=new Map;reconnecting=!1;disposed=!1;constructor(e,t){this.raw=new e({...t})}get account(){return this.raw.account}get connected(){return this.raw.connected}connect(){return this.raw.connected?Promise.resolve(!0):(this.disposed=!1,this.rawConnect())}rawConnect(){return this.raw.connect({onDisconnect:()=>void this.reconnect()})}disconnect(){this.disposed=!0;for(let e of this.active.values())e.off();return this.active.clear(),this.raw.disconnect()}remoteFunction(e,t,n){return this.raw.remoteFunction(e,t,n)}track(e){let t=Symbol(`sub`),n={make:e,off:e()};return this.active.set(t,n),()=>{n.off(),this.active.delete(t)}}async reconnect(){if(!this.disposed&&!this.reconnecting){this.reconnecting=!0;try{await this.rawConnect();for(let e of this.active.values())e.off(),e.off=e.make()}finally{this.reconnecting=!1}}}subscribeRoomState(e,t){return this.track(()=>this.raw.subscribeRoomState(e,t))}subscribeRoomMyState(e,t){return this.track(()=>this.raw.subscribeRoomMyState(e,t))}subscribeRoomAllUserStates(e,t){return this.track(()=>this.raw.subscribeRoomAllUserStates(e,e=>{let n={};for(let t of e??[]){if(!t||typeof t.account!=`string`||t.__leaved)continue;let{account:e,__updated:r,__leaved:i,...a}=t;n[e]=a}t(n)}))}subscribeRoomCollection(e,t,n){return this.track(()=>this.raw.subscribeRoomCollection(e,t,({items:e})=>{let t={};for(let n of e??[])n&&typeof n.__id==`string`&&(t[n.__id]=n);n(t)}))}onRoomMessage(e,t,n){return this.track(()=>this.raw.onRoomMessage(e,t,n))}onRoomUserJoin(e,t){return this.track(()=>this.raw.onRoomUserJoin(e,t))}onRoomUserLeave(e,t){return this.track(()=>this.raw.onRoomUserLeave(e,t))}subscribeGlobalState(e){return this.track(()=>this.raw.subscribeGlobalState(e))}subscribeGlobalMyState(e){return this.track(()=>this.raw.subscribeGlobalMyState(e))}subscribeGlobalUserState(e,t){return this.track(()=>this.raw.subscribeGlobalUserState(e,t))}subscribeGlobalCollection(e,t){return this.track(()=>this.raw.subscribeGlobalCollection(e,({items:e})=>{let n={};for(let t of e??[])t&&typeof t.__id==`string`&&(n[t.__id]=t);t(n)}))}subscribeAsset(e,t){return this.track(()=>this.raw.subscribeAsset(e,t))}onGlobalMessage(e,t){return this.track(()=>this.raw.onGlobalMessage(e,t))}};export{t as createAgent8Server};
1
+ import{n as e}from"./index-5dEIhvsf.js";async function t(t){return new n((await e(()=>import(`./GameServer-C56iOUgF.js`),[],import.meta.url)).GameServer,t)}var n=class{raw;active=new Map;reconnecting=!1;disposed=!1;constructor(e,t){this.raw=new e({...t})}get account(){return this.raw.account}get connected(){return this.raw.connected}connect(){return this.raw.connected?Promise.resolve(!0):(this.disposed=!1,this.rawConnect())}rawConnect(){return this.raw.connect({onDisconnect:()=>void this.reconnect()})}disconnect(){this.disposed=!0;for(let e of this.active.values())e.off();return this.active.clear(),this.raw.disconnect()}remoteFunction(e,t,n){return this.raw.remoteFunction(e,t,n)}track(e){let t=Symbol(`sub`),n={make:e,off:e()};return this.active.set(t,n),()=>{n.off(),this.active.delete(t)}}async reconnect(){if(!this.disposed&&!this.reconnecting){this.reconnecting=!0;try{await this.rawConnect();for(let e of this.active.values())e.off(),e.off=e.make()}finally{this.reconnecting=!1}}}subscribeRoomState(e,t){return this.track(()=>this.raw.subscribeRoomState(e,t))}subscribeRoomMyState(e,t){return this.track(()=>this.raw.subscribeRoomMyState(e,t))}subscribeRoomAllUserStates(e,t){return this.track(()=>this.raw.subscribeRoomAllUserStates(e,e=>{let n={};for(let t of e??[]){if(!t||typeof t.account!=`string`||t.__leaved)continue;let{account:e,__updated:r,__leaved:i,...a}=t;n[e]=a}t(n)}))}subscribeRoomCollection(e,t,n){return this.track(()=>this.raw.subscribeRoomCollection(e,t,({items:e})=>{let t={};for(let n of e??[])n&&typeof n.__id==`string`&&(t[n.__id]=n);n(t)}))}onRoomMessage(e,t,n){return this.track(()=>this.raw.onRoomMessage(e,t,n))}onRoomUserJoin(e,t){return this.track(()=>this.raw.onRoomUserJoin(e,t))}onRoomUserLeave(e,t){return this.track(()=>this.raw.onRoomUserLeave(e,t))}subscribeGlobalState(e){return this.track(()=>this.raw.subscribeGlobalState(e))}subscribeGlobalMyState(e){return this.track(()=>this.raw.subscribeGlobalMyState(e))}subscribeGlobalUserState(e,t){return this.track(()=>this.raw.subscribeGlobalUserState(e,t))}subscribeGlobalCollection(e,t){return this.track(()=>this.raw.subscribeGlobalCollection(e,({items:e})=>{let n={};for(let t of e??[])t&&typeof t.__id==`string`&&(n[t.__id]=t);t(n)}))}subscribeAsset(e,t){return this.track(()=>this.raw.subscribeAsset(e,t))}onGlobalMessage(e,t){return this.track(()=>this.raw.onGlobalMessage(e,t))}};export{t as createAgent8Server};