incanto 0.65.0 → 0.67.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 (88) hide show
  1. package/bin/_behaviors-loader.mjs +87 -0
  2. package/bin/incanto-editor.mjs +39 -2
  3. package/bin/incanto-feel.mjs +4 -1
  4. package/bin/incanto-frame.mjs +52 -3
  5. package/bin/incanto-logs.mjs +33 -3
  6. package/bin/incanto-multiplay.mjs +50 -1
  7. package/bin/incanto-play.mjs +3 -1
  8. package/bin/incanto-playtest.mjs +3 -25
  9. package/bin/incanto-verify.mjs +15 -2
  10. package/dist/2d.d.ts +76 -4
  11. package/dist/2d.js +3 -3
  12. package/dist/3d.d.ts +213 -14
  13. package/dist/3d.js +6 -6
  14. package/dist/{audio-player-BrEHwbK1.d.ts → audio-player-BNUHo0JF.d.ts} +1 -1
  15. package/dist/{behavior-CmMuQ6CL.d.ts → behavior-ZnfzKDo3.d.ts} +58 -1
  16. package/dist/{create-game-DIyh5Zy9.js → create-game-DMetd3vR.js} +14 -8
  17. package/dist/{create-game-W2xzTJhs.js → create-game-FAXdHta3.js} +138 -11
  18. package/dist/debug.d.ts +1 -1
  19. package/dist/debug.js +3 -2
  20. package/dist/{duplicate-CqSAtdrh.js → duplicate-B8QnX2-r.js} +1 -1
  21. package/dist/editor.js +7 -2
  22. package/dist/{environment-presets-BvHs6HEk.js → environment-presets-C-5g2pMh.js} +113 -9
  23. package/dist/{gameplay-D-H986A4.js → gameplay-CGSv-9XR.js} +295 -12
  24. package/dist/gameplay.d.ts +102 -6
  25. package/dist/gameplay.js +2 -2
  26. package/dist/index.d.ts +50 -6
  27. package/dist/index.js +7 -6
  28. package/dist/{loader-CTB441z4.d.ts → loader-DyT30ArB.d.ts} +1 -1
  29. package/dist/{loader-lQDCwNag.js → loader-KZESOZmE.js} +148 -1
  30. package/dist/net.d.ts +2 -2
  31. package/dist/net.js +1 -1
  32. package/dist/{physics-2d-CmnunaR2.js → physics-2d-_B6zj3zU.js} +57 -4
  33. package/dist/{physics-3d-DULB1lD1.js → physics-3d-i1IeAY0F.js} +74 -5
  34. package/dist/{quiet-rapier-BAJ4K94N.js → quiet-rapier-C6fW4zcW.js} +14 -1
  35. package/dist/react.d.ts +1 -1
  36. package/dist/react.js +1 -1
  37. package/dist/{register-CgeR4ckF.js → register-Cs53CY7s.js} +110 -16
  38. package/dist/{register-DO5_Qp0Q.js → register-vIxU3Xbv.js} +38 -4
  39. package/dist/rendering-options-ByAdi8PF.js +184 -0
  40. package/dist/{replay-CEw2xAhG.js → replay-BkpDV51O.js} +4 -3
  41. package/dist/{replay-CThvwkcQ.d.ts → replay-D0GcEHYU.d.ts} +1 -1
  42. package/dist/{split-screen-DxZzC1nm.d.ts → split-screen-DD5eYIgW.d.ts} +2 -2
  43. package/dist/{split-screen-gb0c3xdb.js → split-screen-DS8RaUV_.js} +2 -2
  44. package/dist/{src-DYffzHbd.js → src-BLlsGJW9.js} +1 -1
  45. package/dist/{teardown-BordHL9o.js → teardown-LB9cTnXJ.js} +2 -2
  46. package/dist/{test-CXZuXs8k.js → test-81uZ967a.js} +127 -24
  47. package/dist/test.d.ts +7 -5
  48. package/dist/test.js +2 -2
  49. package/dist/{touch-DESwnpOc.js → touch-DEAmqGdf.js} +30 -186
  50. package/dist/vite.d.ts +0 -7
  51. package/dist/vite.js +13 -3
  52. package/editor/assets/{agent8-DO5qzIOD.js → agent8-C2jtNKbx.js} +1 -1
  53. package/editor/assets/debug-DoU3KeqS.js +3 -0
  54. package/editor/assets/{index-CqEUVaPN.js → index-CNqHojcC.js} +92 -92
  55. package/editor/index.html +1 -1
  56. package/package.json +1 -1
  57. package/schemas/scene.schema.json +20 -1
  58. package/skills/incanto-building-3d-games.md +19 -0
  59. package/skills/incanto-editor.md +5 -1
  60. package/skills/incanto-environment.md +2 -2
  61. package/skills/incanto-gameplay-behaviors.md +86 -7
  62. package/skills/incanto-hud.md +14 -1
  63. package/skills/incanto-multiplayer.md +14 -0
  64. package/skills/incanto-node-reference.md +60 -2
  65. package/skills/incanto-performance.md +35 -0
  66. package/skills/incanto-physics-and-input.md +69 -0
  67. package/skills/incanto-playtesting.md +15 -1
  68. package/skills/incanto-save-slots.md +21 -1
  69. package/skills/incanto-verifying-your-game.md +19 -0
  70. package/skills/incanto-web-integration.md +24 -3
  71. package/skills/incanto-your-first-game.md +4 -4
  72. package/templates-app/beacon-isle-3d/package.json +1 -1
  73. package/templates-app/beacon-isle-3d/src/game.scene.json +38 -19
  74. package/templates-app/platformer-2d/package.json +1 -1
  75. package/templates-app/platformer-2d/src/game.scene.json +99 -53
  76. package/templates-app/star-survivor/package.json +1 -1
  77. package/templates-app/star-survivor/src/game.scene.json +31 -17
  78. package/templates-app/tps-3d/package.json +1 -1
  79. package/templates-app/tps-3d/src/game.scene.json +14 -8
  80. package/templates-app/village-quest-3d/package.json +1 -1
  81. package/templates-app/village-quest-3d/src/grove.scene.json +20 -11
  82. package/templates-app/village-quest-3d/src/village.scene.json +10 -7
  83. package/editor/assets/debug-DntG9CVD.js +0 -3
  84. package/templates-app/beacon-isle-3d/coverage.json +0 -9
  85. package/templates-app/platformer-2d/coverage.json +0 -5
  86. package/templates-app/star-survivor/coverage.json +0 -5
  87. package/templates-app/tps-3d/coverage.json +0 -5
  88. package/templates-app/village-quest-3d/coverage.json +0 -9
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Load a `--behaviors` file the way the author WROTE it, from any bin.
3
+ *
4
+ * Every template's `behaviors.ts` does `import gameJson from './game.scene.json'`
5
+ * — the documented way to read your own scene — and node's type stripping
6
+ * refuses a JSON import without `with { type: 'json' }`. Same for an
7
+ * extensionless relative import (`from './quest'`), which is what the tsconfig
8
+ * this package scaffolds asks for. Vite reads both. bun reads both. The bins'
9
+ * shebang is node.
10
+ *
11
+ * `incanto-playtest` grew a bun re-exec for exactly this and **the other four
12
+ * bins that take `--behaviors` never got it**: `feel`, `play`, `verify` and
13
+ * `multiplay` all died on the shipped `village-quest-3d`, and the only
14
+ * invocation the docs give (`incanto-verifying-your-game.md`) is the failing
15
+ * form. Worse, `verify:templates` — the gate step added specifically to run a
16
+ * template under node's resolver — runs `incanto-playtest`, the one bin whose
17
+ * fallback hides the failure. The step built to catch this class was
18
+ * neutralised by the thing it was built to catch.
19
+ *
20
+ * So the fallback lives here, once, and every bin uses it. Set
21
+ * `INCANTO_NO_BUN_REEXEC=1` to forbid it — that is how the gate exercises the
22
+ * resolver a user actually gets.
23
+ */
24
+ import { spawnSync } from 'node:child_process';
25
+
26
+ /**
27
+ * Is this node refusing the file, rather than the file being wrong?
28
+ *
29
+ * Matched against the SPECIFIER where node gives us one. Node words the
30
+ * extensionless failure as `Cannot find module '<resolved path>' imported from
31
+ * '<importer>'`, and a check that reads the importer collapses it into
32
+ * "(file not found)" naming the file that plainly exists — which is what the
33
+ * author sees and cannot act on.
34
+ */
35
+ export function nodeCannotReadIt(why) {
36
+ return (
37
+ /import attribute|Unknown file extension|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_UNSUPPORTED_[A-Z_]+/i.test(
38
+ String(why),
39
+ ) ||
40
+ // Node's type stripping ERASES types; it does not transform. Anything that
41
+ // needs a transform is refused by name, and the wording is nothing like the
42
+ // resolver's. Measured on the shipped `racing-3d` example:
43
+ //
44
+ // x TypeScript parameter property is not supported in strip-only mode
45
+ //
46
+ // `constructor(private root: Node3D)` is ordinary TypeScript that vite and
47
+ // bun both compile, and the bin gave up with "Pass the file that exports
48
+ // your Behavior subclasses" — telling the author their correct file was
49
+ // wrong. Enums and namespaces are refused the same way.
50
+ /strip-only mode|is not supported in strip|only mode|TypeScript parameter property|Unsupported TypeScript/i.test(
51
+ String(why),
52
+ )
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Re-run this bin under bun, once, if node is the reason it failed.
58
+ *
59
+ * Never returns when it re-execs. Returns false when it did not (already bun,
60
+ * already re-execed, forbidden, no bun on PATH, or a real error in the file).
61
+ */
62
+ export function reexecUnderBun(entryUrl, why) {
63
+ if (!nodeCannotReadIt(why)) return false;
64
+ if (process.versions.bun) return false;
65
+ if (process.env.INCANTO_BUN_REEXEC || process.env.INCANTO_NO_BUN_REEXEC) return false;
66
+ if (spawnSync('bun', ['--version'], { stdio: 'ignore' }).status !== 0) return false;
67
+ const here = new URL(entryUrl).pathname;
68
+ const again = spawnSync('bun', [here, ...process.argv.slice(2)], {
69
+ stdio: 'inherit',
70
+ env: { ...process.env, INCANTO_BUN_REEXEC: '1' },
71
+ });
72
+ process.exit(again.status ?? 1);
73
+ }
74
+
75
+ /** The sentence to print when the re-exec was not available. */
76
+ export function behaviorsHelp(file, why) {
77
+ return (
78
+ `could not load --behaviors '${file}' (${why}).` +
79
+ (nodeCannotReadIt(why)
80
+ ? '\n\nThis is usually node refusing your file rather than a problem with it:' +
81
+ "\nnode cannot import JSON from a .ts module without `with { type: 'json' }`," +
82
+ '\nand it will not resolve an extensionless relative import. Both are what' +
83
+ '\nthe scaffolded tsconfig asks you to write. Run it with bun, which reads' +
84
+ '\nthe file as written — prefix any incanto command with `bunx --bun`.'
85
+ : '\nPass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.')
86
+ );
87
+ }
@@ -200,6 +200,29 @@ async function proxyLibrary(req, res, url) {
200
200
  }
201
201
  }
202
202
 
203
+ /**
204
+ * What kind of project is this — read from the scenes already in it.
205
+ *
206
+ * A 3D game's new scene must not be born 2D, and the only thing that knows
207
+ * which is which is what is already on disk. Returns null for an empty project,
208
+ * where there is genuinely nothing to go on.
209
+ */
210
+ function projectDimension() {
211
+ let d2 = 0;
212
+ let d3 = 0;
213
+ for (const entry of discoverScenes()) {
214
+ try {
215
+ const json = JSON.parse(readFileSync(join(ROOT, entry.rel ?? entry), 'utf8'));
216
+ if (json?.dimension === '3d') d3 += 1;
217
+ else if (json?.dimension === '2d') d2 += 1;
218
+ } catch {
219
+ // not a scene, or unreadable — the scene browser already says so
220
+ }
221
+ }
222
+ if (d3 === 0 && d2 === 0) return null;
223
+ return d3 > d2 ? '3d' : '2d';
224
+ }
225
+
203
226
  function discoverScenes() {
204
227
  const found = [];
205
228
  const walk = (dir, depth) => {
@@ -418,12 +441,26 @@ const server = createServer((req, res) => {
418
441
  return send(res, 409, JSON.stringify({ error: `already exists: ${wanted}` }));
419
442
  }
420
443
  const name = basename(abs).replace(/\.scene\.json$|\.json$/, '') || 'Scene';
444
+ /*
445
+ * A 3D project's new scene must not be born 2D.
446
+ *
447
+ * This hardcoded `dimension: '2d'` / `Node2D` and ignored `body`
448
+ * entirely, so every scene the editor created in a 3D game was the
449
+ * wrong kind — and node types are IMMUTABLE by design, so the halfway
450
+ * state (a 3D header over a Node2D root) is easy to reach through the
451
+ * header's dimension select and is written back with a 200.
452
+ *
453
+ * Asked for explicitly, or guessed from the project: whatever the
454
+ * sibling scenes are is overwhelmingly what the next one should be.
455
+ */
456
+ const asked = body?.dimension === '3d' || body?.dimension === '2d' ? body.dimension : null;
457
+ const dimension = asked ?? projectDimension() ?? '2d';
421
458
  const scene = {
422
459
  format: 1,
423
460
  type: 'scene',
424
- dimension: '2d',
461
+ dimension,
425
462
  name,
426
- root: { name, type: 'Node2D' },
463
+ root: { name, type: dimension === '3d' ? 'Node3D' : 'Node2D' },
427
464
  };
428
465
  try {
429
466
  mkdirSync(dirname(abs), { recursive: true });
@@ -21,6 +21,7 @@ import { dirname, join, resolve } from 'node:path';
21
21
  * wrong for most games.
22
22
  */
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
24
+ import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
24
25
 
25
26
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
26
27
 
@@ -63,7 +64,9 @@ if (args.behaviors) {
63
64
  try {
64
65
  mod = await import(pathToFileURL(resolve(args.behaviors)).href);
65
66
  } catch (e) {
66
- console.error(`could not load --behaviors '${args.behaviors}' (${e?.message ?? e}).`);
67
+ const why = e?.message ?? String(e);
68
+ reexecUnderBun(import.meta.url, why);
69
+ console.error(behaviorsHelp(args.behaviors, why));
67
70
  process.exit(1);
68
71
  }
69
72
  for (const [name, value] of Object.entries(mod)) {
@@ -150,11 +150,33 @@ const discovered = explicit ? [explicit] : await listeningPorts({ read });
150
150
  const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
151
151
 
152
152
  let found = null;
153
+ /**
154
+ * WHOSE dev server answered.
155
+ *
156
+ * This scans a port range, so before the ping carried identity, whoever
157
+ * answered first won — and a passing number from ANOTHER project silently
158
+ * overrode this one's failing one. Measured: project A on :5188 with a real
159
+ * page reported "the subject fills almost nothing (0.3%)", while `incanto
160
+ * verify` scanning from the same directory reported `✓ draws — 37.5%`,
161
+ * measured on project B. Corroboration from the other direction: four scenes
162
+ * across two unrelated projects all reported 22.6%.
163
+ *
164
+ * So: prefer the server rooted at THIS directory. If several answer and none is
165
+ * ours, refuse and list them — the same refusal `verify` already makes when a
166
+ * project has several scenes and guessing would verify the wrong one.
167
+ */
168
+ const answered = [];
153
169
  // BOTH hosts, deliberately: vite binds to ::1 on some machines and 127.0.0.1 on
154
170
  // others, and a probe of the wrong one reports "no dev server" while the page
155
171
  // is plainly on screen.
156
172
  const HOSTS = ['localhost', '127.0.0.1'];
157
- outer: for (const port of ports) {
173
+ const CWD = process.cwd();
174
+ const sameProject = (b) => {
175
+ if (!b?.root) return false;
176
+ const r = String(b.root);
177
+ return r === CWD || CWD.startsWith(`${r}/`) || r.startsWith(`${CWD}/`);
178
+ };
179
+ for (const port of ports) {
158
180
  for (const host of HOSTS) {
159
181
  try {
160
182
  const res = await fetch(`http://${host}:${port}/__incanto/ping`, {
@@ -163,8 +185,8 @@ outer: for (const port of ports) {
163
185
  if (!res.ok) continue;
164
186
  const body = await res.json();
165
187
  if (body?.incanto) {
166
- found = { host, port, ...body };
167
- break outer;
188
+ answered.push({ host, port, ...body });
189
+ break; // one host per port is enough
168
190
  }
169
191
  } catch {
170
192
  // not listening, not ours, or not answering — either way, next
@@ -172,6 +194,33 @@ outer: for (const port of ports) {
172
194
  }
173
195
  }
174
196
 
197
+ if (explicit) {
198
+ found = answered[0] ?? null;
199
+ } else {
200
+ const mine = answered.filter(sameProject);
201
+ if (mine.length > 0) {
202
+ found = mine[0];
203
+ } else if (answered.length === 1 && answered[0].root === undefined) {
204
+ // An older dev server that predates the identity field. Take it, but say so.
205
+ found = answered[0];
206
+ console.error(
207
+ `· the dev server on :${found.port} does not report which project it is ` +
208
+ '(older incanto) — if this reads the wrong game, pass --port.',
209
+ );
210
+ } else if (answered.length > 0) {
211
+ console.error(
212
+ `${answered.length} incanto dev server(s) are running and NONE of them is this ` +
213
+ `directory (${CWD}):\n` +
214
+ answered
215
+ .map((a) => ` :${a.port} ${a.project || '(unnamed)'} ${a.root ?? '?'}`)
216
+ .join('\n') +
217
+ "\n\nMeasuring one project on another project's page reports a number that has\n" +
218
+ "nothing to do with this game. Start this project's dev server, or pass --port.",
219
+ );
220
+ process.exit(1);
221
+ }
222
+ }
223
+
175
224
  if (!found) {
176
225
  console.error(
177
226
  'no incanto dev server found.\n' +
@@ -75,7 +75,17 @@ const discovered = explicit ? [explicit] : await listeningPorts({ read });
75
75
  const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
76
76
 
77
77
  let found = null;
78
- outer: for (const port of ports) {
78
+ // Whose dev server answered see incanto-frame.mjs for the measurement that
79
+ // made this necessary: a scan takes whoever replies first, and a foreign
80
+ // project's page answered both the frame and the logs request.
81
+ const answered = [];
82
+ const CWD = process.cwd();
83
+ const sameProject = (b) => {
84
+ if (!b?.root) return false;
85
+ const r = String(b.root);
86
+ return r === CWD || CWD.startsWith(`${r}/`) || r.startsWith(`${CWD}/`);
87
+ };
88
+ for (const port of ports) {
79
89
  for (const host of ['localhost', '127.0.0.1']) {
80
90
  try {
81
91
  const res = await fetch(`http://${host}:${port}/__incanto/ping`, {
@@ -84,14 +94,34 @@ outer: for (const port of ports) {
84
94
  if (!res.ok) continue;
85
95
  const body = await res.json();
86
96
  if (body?.incanto) {
87
- found = { host, port };
88
- break outer;
97
+ answered.push({ host, port, ...body });
98
+ break;
89
99
  }
90
100
  } catch {
91
101
  // not listening, not ours, or not answering — next
92
102
  }
93
103
  }
94
104
  }
105
+ if (explicit) {
106
+ found = answered[0] ? { host: answered[0].host, port: answered[0].port } : null;
107
+ } else {
108
+ const mine = answered.filter(sameProject);
109
+ const pick =
110
+ mine[0] ?? (answered.length === 1 && answered[0].root === undefined ? answered[0] : null);
111
+ if (pick) found = { host: pick.host, port: pick.port };
112
+ else if (answered.length > 0) {
113
+ console.error(
114
+ `${answered.length} incanto dev server(s) are running and none is this directory ` +
115
+ `(${CWD}):\n` +
116
+ answered
117
+ .map((a) => ` :${a.port} ${a.project || '(unnamed)'} ${a.root ?? '?'}`)
118
+ .join('\n') +
119
+ "\n\nReading another project's log buffer would report its errors as yours.\n" +
120
+ "Start this project's dev server, or pass --port.",
121
+ );
122
+ process.exit(1);
123
+ }
124
+ }
95
125
 
96
126
  if (!found) {
97
127
  console.error(
@@ -20,6 +20,7 @@
20
20
  import { existsSync, readFileSync } from 'node:fs';
21
21
  import { dirname, join, resolve } from 'node:path';
22
22
  import { fileURLToPath, pathToFileURL } from 'node:url';
23
+ import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
23
24
 
24
25
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
25
26
 
@@ -36,6 +37,7 @@ if (argv.includes('--help') || argv.includes('-h')) {
36
37
  --seconds N simulated seconds (default 5)
37
38
  --seed N make the match reproducible (default 1)
38
39
  --behaviors FILE your Behavior subclasses (.ts works on bun / node >= 23.6)
40
+ --server FILE your Server class (default: server/src/server.ts if present)
39
41
  --scenes a=b.json a scene a NetworkSpawner names, repeatable
40
42
  --no-late-join skip the late-joining client
41
43
  --json machine-readable report
@@ -83,7 +85,9 @@ if (behaviorsFile) {
83
85
  }
84
86
  }
85
87
  } catch (e) {
86
- console.error(`could not load --behaviors '${behaviorsFile}' (${e?.message ?? e}).`);
88
+ const why = e?.message ?? String(e);
89
+ reexecUnderBun(import.meta.url, why);
90
+ console.error(behaviorsHelp(behaviorsFile, why));
87
91
  process.exit(1);
88
92
  }
89
93
  }
@@ -122,11 +126,56 @@ for (const name of spawnerScenes) {
122
126
  }
123
127
  }
124
128
 
129
+ /**
130
+ * Your REAL server class, so `agrees` measures the game you ship.
131
+ *
132
+ * `playMultiplayer` has always accepted a `server`, and this bin never passed
133
+ * one — so the rung ran every match against a bare loopback kernel with no
134
+ * remote functions. Measured: with no server `roomState` came back `{}`, with
135
+ * one it came back `{"matchMs": 5983.3}`; a `manager.call('claimRelic')` threw
136
+ * `BAD_FORMAT: Loopback kernel has no remote function 'claimRelic'` and the
137
+ * report STILL said `ok=true errors=0 problems=[]`. Moving `server/src/server.ts`
138
+ * out of the tree changed nothing, which is the whole finding: a ✓ that could
139
+ * not have been a ✗.
140
+ *
141
+ * Found where the templates put it, or named with `--server`.
142
+ */
143
+ let server;
144
+ const serverFlag = flag('--server', null);
145
+ const serverGuesses = serverFlag
146
+ ? [resolve(serverFlag)]
147
+ : ['server/src/server.ts', 'server/src/server.js', 'src/server.ts'].map((g) =>
148
+ resolve(sceneDir, '..', '..', g),
149
+ );
150
+ for (const at of serverGuesses) {
151
+ if (!existsSync(at)) continue;
152
+ try {
153
+ const mod = await import(pathToFileURL(at).href);
154
+ server = mod.Server ?? mod.default;
155
+ if (server) {
156
+ if (!argv.includes('--json')) console.log(`· server: ${at} (${server.name || 'default'})`);
157
+ break;
158
+ }
159
+ } catch (e) {
160
+ // A server that cannot be loaded must not read as a game that has none.
161
+ console.error(
162
+ `could not load the server at '${at}' (${e?.message ?? e}). ` +
163
+ 'The agreement check would then measure a game without its own rules; refusing.',
164
+ );
165
+ process.exit(1);
166
+ }
167
+ }
168
+ if (serverFlag && !server) {
169
+ console.error(`--server '${serverFlag}' exports neither \`Server\` nor a default.`);
170
+ process.exit(1);
171
+ }
172
+
125
173
  let report;
126
174
  try {
127
175
  report = await playMultiplayer({
128
176
  scene: sceneJson,
129
177
  scenes,
178
+ ...(server ? { server } : {}),
130
179
  players: Number(flag('--players', 2)) || 2,
131
180
  seconds: Number(flag('--seconds', 5)) || 5,
132
181
  seed: Number(flag('--seed', 1)) || 1,
@@ -26,6 +26,7 @@ import { dirname, join, resolve } from 'node:path';
26
26
  */
27
27
  import { createInterface } from 'node:readline';
28
28
  import { fileURLToPath, pathToFileURL } from 'node:url';
29
+ import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
29
30
 
30
31
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
31
32
 
@@ -79,6 +80,7 @@ if (args.behaviors) {
79
80
  try {
80
81
  mod = await import(pathToFileURL(resolve(args.behaviors)).href);
81
82
  } catch (e) {
83
+ reexecUnderBun(import.meta.url, e?.message ?? String(e));
82
84
  const reason =
83
85
  e?.code === 'ERR_MODULE_NOT_FOUND' && String(e.message).includes(resolve(args.behaviors))
84
86
  ? 'file not found'
@@ -88,7 +90,7 @@ if (args.behaviors) {
88
90
  cmd: 'behaviors',
89
91
  error: {
90
92
  code: e?.code ?? 'BEHAVIORS_LOAD_FAILED',
91
- message: `could not load --behaviors '${args.behaviors}' (${reason}). Pass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.`,
93
+ message: behaviorsHelp(args.behaviors, reason),
92
94
  },
93
95
  });
94
96
  process.exit(1);
@@ -23,6 +23,7 @@ import { dirname, join, resolve } from 'node:path';
23
23
  * Exit code 1 when no run reached a win, so CI can gate on it.
24
24
  */
25
25
  import { fileURLToPath, pathToFileURL } from 'node:url';
26
+ import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
26
27
 
27
28
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
28
29
 
@@ -92,31 +93,8 @@ if (args.behaviors) {
92
93
  // and vite resolve it. It broke `incanto verify` on the shipped
93
94
  // `village-quest-3d` starter, and it breaks for any author who splits their
94
95
  // behaviours across files — which is most of them, eventually.
95
- const nodeCannotReadIt = /import attribute|Unknown file extension|Cannot find module/i.test(
96
- why,
97
- );
98
- if (nodeCannotReadIt && !process.versions.bun && !process.env.INCANTO_BUN_REEXEC) {
99
- const { spawnSync } = await import('node:child_process');
100
- const hasBun = spawnSync('bun', ['--version'], { stdio: 'ignore' }).status === 0;
101
- if (hasBun) {
102
- const here = fileURLToPath(import.meta.url);
103
- const again = spawnSync('bun', [here, ...process.argv.slice(2)], {
104
- stdio: 'inherit',
105
- env: { ...process.env, INCANTO_BUN_REEXEC: '1' },
106
- });
107
- process.exit(again.status ?? 1);
108
- }
109
- }
110
- console.error(
111
- `could not load --behaviors '${args.behaviors}' (${why}).` +
112
- (nodeCannotReadIt
113
- ? '\n\nThis is usually node refusing your file rather than a problem with it:' +
114
- "\nnode cannot import JSON from a .ts module without `with { type: 'json' }`," +
115
- '\nand it will not resolve an extensionless relative import. Both are what' +
116
- '\nthe scaffolded tsconfig asks you to write. Run it with bun, which reads' +
117
- '\nthe file as written: `bunx incanto playtest <scene> --behaviors <file>`.'
118
- : '\nPass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.'),
119
- );
96
+ reexecUnderBun(import.meta.url, why);
97
+ console.error(behaviorsHelp(args.behaviors, why));
120
98
  process.exit(1);
121
99
  }
122
100
  for (const [name, value] of Object.entries(mod)) {
@@ -84,6 +84,19 @@ function findScene() {
84
84
  }
85
85
 
86
86
  const bin = (name) => join(PKG, 'bin', `incanto-${name}.mjs`);
87
+
88
+ /**
89
+ * `--port`, passed straight through to the page-reading rungs.
90
+ *
91
+ * `draws` and `says` ask a dev server for the browser's answer, and discovery
92
+ * scans a port range — so with two projects running, this one measured the
93
+ * OTHER one's page and reported its pass. `incanto-frame`/`incanto-logs` now
94
+ * refuse a foreign server, and this makes the escape hatch they document
95
+ * reachable from the rung that actually runs them.
96
+ */
97
+ const PORT = process.argv.includes('--port')
98
+ ? ['--port', String(process.argv[process.argv.indexOf('--port') + 1])]
99
+ : [];
87
100
  const run = (name, args) =>
88
101
  spawnSync(process.execPath, [bin(name), ...args], {
89
102
  encoding: 'utf-8',
@@ -424,7 +437,7 @@ if (rungs[0].status === 'pass') {
424
437
 
425
438
  // ---- draws ---------------------------------------------------------------
426
439
  {
427
- const r = run('frame', ['--json']);
440
+ const r = run('frame', ['--json', ...PORT]);
428
441
  const out = safeJson(r.stdout);
429
442
  const report = out?.report;
430
443
  if (!report) {
@@ -466,7 +479,7 @@ if (rungs[0].status === 'pass') {
466
479
  */
467
480
  // ---- says ---------------------------------------------------------------
468
481
  {
469
- const r = run('logs', ['--json']);
482
+ const r = run('logs', ['--json', ...PORT]);
470
483
  const report = safeJson(r.stdout);
471
484
  if (!report) {
472
485
  rungs.push({
package/dist/2d.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, jt as Node, n as BehaviorCtor, w as GameStats } from "./behavior-CmMuQ6CL.js";
1
+ import { P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, jt as Node, n as BehaviorCtor, w as GameStats, x as EngineOptions } from "./behavior-ZnfzKDo3.js";
2
2
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
3
  import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
4
4
  import { r as FrameStats } from "./frame-report-DCnHFmto.js";
5
- import { t as LoadSceneOptions } from "./loader-CTB441z4.js";
5
+ import { t as LoadSceneOptions } from "./loader-DyT30ArB.js";
6
6
  import { n as AnimationEntry } from "./sprite-animation-CMr6f1K2.js";
7
- import { i as ParticleSim, o as ParticleView, r as SpatialPose } from "./audio-player-BrEHwbK1.js";
7
+ import { i as ParticleSim, o as ParticleView, r as SpatialPose } from "./audio-player-BNUHo0JF.js";
8
8
  import { Group, Mesh, Object3D, Scene, Texture } from "three";
9
9
  import * as RapierNs from "@dimforge/rapier2d-compat";
10
10
 
@@ -157,6 +157,11 @@ declare class StaticBody2D extends PhysicsBody2D {
157
157
  */
158
158
  declare class Area2D extends PhysicsBody2D {
159
159
  static override readonly typeName: string;
160
+ /**
161
+ * Who is inside RIGHT NOW — the standing answer behind the enter/exit pair.
162
+ * See `Area3D.overlapping`. Empty in a scene with no physics world.
163
+ */
164
+ overlapping(group?: string): PhysicsBody2D[];
160
165
  }
161
166
  /** Dynamic simulated body. */
162
167
  declare class RigidBody2D extends PhysicsBody2D {
@@ -271,6 +276,21 @@ declare class Physics2D {
271
276
  private scopedLines;
272
277
  /** Create newly-arrived joints; tear down departed ones. */
273
278
  private syncJoints;
279
+ /** Who is currently inside each sensor/body, maintained from the event drain. */
280
+ private readonly overlaps;
281
+ private trackOverlap;
282
+ /**
283
+ * Forget every overlap whose node has left the tree.
284
+ *
285
+ * The lazy drop inside `overlapping()` only cleans the SETS of a key someone
286
+ * asks about, and only when they ask. This clears the KEYS, which is where the
287
+ * dead nodes were pinned.
288
+ */
289
+ /** Drop one node from the overlap map, both as a key and as a member. */
290
+ private forgetOverlaps;
291
+ private pruneOverlaps;
292
+ /** The bodies currently overlapping `node`. Freed nodes are dropped on read. */
293
+ overlapping(node: PhysicsBody2D): PhysicsBody2D[];
274
294
  /**
275
295
  * World-space raycast in PIXELS (y-down). `exclude` skips that body — a
276
296
  * shooter probing from inside its own collider needs it. Sensors (Area2D)
@@ -376,6 +396,18 @@ interface CreateGame2DOptions {
376
396
  createElement(tag: string): HTMLElement;
377
397
  };
378
398
  seed?: number;
399
+ /**
400
+ * Namespace this game's persisted settings — volume, language, quality tier.
401
+ *
402
+ * They are keyed `incanto:<namespace>:<key>` in the browser's storage and
403
+ * defaulted to `settings` for everyone, so two Incanto games on one domain
404
+ * shared one options screen. Measured: game A's player sets `renderScale
405
+ * 0.25, muted, quality low`; game B, whose source never mentions settings,
406
+ * boots at a 480x232 drawing buffer and silent.
407
+ *
408
+ * `SaveSlots(namespace)` already takes one for exactly this reason.
409
+ */
410
+ settings?: EngineOptions["settings"];
379
411
  fixedHz?: number;
380
412
  /** Renderer extras (pixelRatio, antialias, custom asset store). */
381
413
  pixelRatio?: number;
@@ -415,7 +447,22 @@ interface Game2DRenderer extends GameRenderer {
415
447
  }
416
448
  interface Game2D {
417
449
  engine: Engine;
418
- scene: Scene$1;
450
+ /**
451
+ * The LIVE scene — a getter over `engine.scene`, not a snapshot.
452
+ *
453
+ * This used to be the scene captured at boot. `Engine.setScene` calls
454
+ * `this._scene?.root.free()`, so after any swap the old handle was not merely
455
+ * stale, it was EMPTIED — and a single-scene game reaches that through
456
+ * `flow.restart()`, which `incanto-hud.md` wires to a Start button. Measured:
457
+ *
458
+ * before swap game.scene.root.children = 2 engine.scene = 2
459
+ * after swap game.scene.root.children = 0 engine.scene = 2
460
+ * game.scene.root.getNode('Flow') -> No node at 'Flow'. Children here: [].
461
+ *
462
+ * `game.engine.scene` always worked and three skills use it, so there were
463
+ * two handles with one name and no note saying which was which.
464
+ */
465
+ get scene(): Scene$1;
419
466
  /**
420
467
  * What the last drawn frame actually looks like, as numbers.
421
468
  *
@@ -719,6 +766,31 @@ declare class Joint2D extends Node2D {
719
766
  length: number;
720
767
  stiffness: number;
721
768
  damping: number;
769
+ /**
770
+ * Do the two linked bodies still collide with EACH OTHER?
771
+ *
772
+ * `null` (default) decides from the joint type, because the two families want
773
+ * opposite answers:
774
+ *
775
+ * - **pivots** (`fixed`, `spherical`, `revolute`) → **off**. A hinge's bodies
776
+ * overlap at the pivot by construction, so the contact solver fights the
777
+ * joint and flings them. A puzzle builder's first hinge produced a bar that
778
+ * spun chaotically forever and threw a 40 kg box thirty metres, and they
779
+ * nearly filed "spherical joints inject energy":
780
+ *
781
+ * ```
782
+ * t= 0 Plank r=[0,0,0] Box [ 0.00, 5.50, 2.40]
783
+ * t=3000 Plank r=[-66,24,64] Box [-29.70, 0.40, -4.91]
784
+ * ```
785
+ *
786
+ * - **tethers** (`rope`, `spring`) → **on**. These anchor a thing to the world,
787
+ * and the thing has to rest on what it is tethered to. Defaulting these off
788
+ * drops a barrel roped to the floor straight through it — measured, in this
789
+ * repo's own conformance room.
790
+ *
791
+ * Set `true`/`false` to say it outright.
792
+ */
793
+ collide: boolean | null;
722
794
  override onEnterTree(): void;
723
795
  /** @internal The two bodies, resolved (throws on a bad target at step time). */
724
796
  _resolveBodies(): {
package/dist/2d.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-DIyh5Zy9.js";
3
- import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-DO5_Qp0Q.js";
4
- import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-CmnunaR2.js";
2
+ import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-DMetd3vR.js";
3
+ import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-vIxU3Xbv.js";
4
+ import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-_B6zj3zU.js";
5
5
  //#region src/2d/library-sprite.ts
6
6
  /**
7
7
  * What a `CharacterController2D`/`3D` will ask a skin to play, and the clip in