incanto 0.57.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 (63) 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 +87 -15
  6. package/bin/incanto.mjs +106 -0
  7. package/dist/2d.js +2 -2
  8. package/dist/3d.js +2 -2
  9. package/dist/{create-game-DpbUrMOQ.js → create-game-BZwWJIns.js} +1 -1
  10. package/dist/{create-game-C5jQYPah.js → create-game-D10bU5_J.js} +56 -8
  11. package/dist/index.js +1 -1
  12. package/dist/{physics-2d-BLcvEFDR.js → physics-2d-EqA-jddf.js} +2 -1
  13. package/dist/{physics-3d-QBrfIT2Y.js → physics-3d-Dnz4fsXX.js} +2 -1
  14. package/dist/quiet-rapier-BAJ4K94N.js +46 -0
  15. package/dist/react.js +1 -1
  16. package/dist/{src-CGjmPw65.js → src-C1J09Op6.js} +1 -1
  17. package/dist/{test-it1VekWs.js → test-ZBga8kQ9.js} +36 -10
  18. package/dist/test.d.ts +1 -1
  19. package/dist/test.js +1 -1
  20. package/dist/vite.js +2 -2
  21. package/editor/assets/{agent8-CGT7r3Mb.js → agent8-BQQjE9UQ.js} +1 -1
  22. package/editor/assets/{debug-BxWSIHG3.js → debug-C9UCsXBS.js} +1 -1
  23. package/editor/assets/{index-CV1m-aX5.js → index-CBgfM3WD.js} +51 -51
  24. package/editor/index.html +1 -1
  25. package/package.json +2 -1
  26. package/skills/incanto-building-2d-games.md +13 -0
  27. package/skills/incanto-building-3d-games.md +3 -3
  28. package/skills/incanto-playtesting.md +19 -5
  29. package/skills/incanto-verifying-your-game.md +11 -4
  30. package/templates-app/beacon-isle-3d/package.json +1 -1
  31. package/templates-app/beacon-isle-3d/src/game.scene.json +7 -6
  32. package/templates-app/platformer-2d/PROJECT/Context.md +70 -0
  33. package/templates-app/platformer-2d/PROJECT/Requirements.md +63 -0
  34. package/templates-app/platformer-2d/PROJECT/Status.md +60 -0
  35. package/templates-app/platformer-2d/PROJECT/Structure.md +77 -0
  36. package/templates-app/platformer-2d/docs/project-2d-rules.md +61 -0
  37. package/templates-app/platformer-2d/index.html +99 -0
  38. package/templates-app/platformer-2d/package.json +23 -0
  39. package/templates-app/platformer-2d/src/behaviors.ts +541 -0
  40. package/templates-app/platformer-2d/src/game.scene.json +2061 -0
  41. package/templates-app/platformer-2d/src/main.ts +68 -0
  42. package/templates-app/platformer-2d/tsconfig.json +13 -0
  43. package/templates-app/platformer-2d/verify.ts +275 -0
  44. package/templates-app/platformer-2d/vite.config.ts +12 -0
  45. package/templates-app/star-survivor/PROJECT/Context.md +55 -0
  46. package/templates-app/star-survivor/PROJECT/Requirements.md +47 -0
  47. package/templates-app/star-survivor/PROJECT/Status.md +44 -0
  48. package/templates-app/star-survivor/PROJECT/Structure.md +63 -0
  49. package/templates-app/star-survivor/docs/project-2d-rules.md +53 -0
  50. package/templates-app/star-survivor/index.html +232 -0
  51. package/templates-app/star-survivor/package.json +23 -0
  52. package/templates-app/star-survivor/src/behaviors.ts +624 -0
  53. package/templates-app/star-survivor/src/game.scene.json +464 -0
  54. package/templates-app/star-survivor/src/main.ts +49 -0
  55. package/templates-app/star-survivor/tsconfig.json +13 -0
  56. package/templates-app/star-survivor/verify.ts +193 -0
  57. package/templates-app/star-survivor/vite.config.ts +12 -0
  58. package/templates-app/tps-3d/package.json +1 -1
  59. package/templates-app/tps-3d/src/game.scene.json +6 -3
  60. package/templates-app/tps-3d/verify.ts +17 -1
  61. package/templates-app/village-quest-3d/package.json +1 -1
  62. package/templates-app/village-quest-3d/src/grove.scene.json +14 -13
  63. package/templates-app/village-quest-3d/src/village.scene.json +5 -5
package/README.md CHANGED
@@ -6,7 +6,8 @@ rule is JSON an AI agent can read, diff, and rewrite — rendered by three.js.
6
6
  ## Start with a whole game
7
7
 
8
8
  ```bash
9
- bunx incanto-new my-game # Beacon Isle — the flagship 3D template
9
+ bunx incanto new my-game # Beacon Isle — the flagship 3D template
10
+ bunx incanto new my-2d --template platformer-2d # the 2D flagship
10
11
  cd my-game && bun install && bun run dev
11
12
  ```
12
13
 
@@ -20,8 +21,9 @@ rest declared in JSON. `bun run verify` plays the entire quest **headlessly**
20
21
  and replays recorded input bit-identically: the agent loop is author → verify →
21
22
  fix, no browser needed.
22
23
 
23
- `bunx incanto-new --list` shows the other starters (third-person shooter,
24
- quest vignette).
24
+ `bunx incanto new --list` shows all five starters, grouped by dimension: three
25
+ 3D (island adventure, third-person shooter, quest vignette) and two 2D
26
+ (platformer, survivors-like).
25
27
 
26
28
  ## Or wire the engine yourself
27
29
 
@@ -76,7 +78,7 @@ in a world full of them). In code: `physics.debugDraw = true` + `physics.debugSc
76
78
 
77
79
  ## The agent8 asset library
78
80
 
79
- `bunx incanto-editor --token <v8 access token>` (or `INCANTO_V8_TOKEN`) adds a
81
+ `bunx incanto editor --token <v8 access token>` (or `INCANTO_V8_TOKEN`) adds a
80
82
  **📚** button to every field that takes a resource — model, texture, sprite
81
83
  sheet, sound. Shelves, search, real previews (a GLB is rendered by the engine
82
84
  itself), and one pick writes the URL, or the scene `assets{}` entry plus its
@@ -14,6 +14,7 @@
14
14
  * each scene file.
15
15
  */
16
16
  import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { createRequire } from 'node:module';
17
18
  import { dirname, join, resolve } from 'node:path';
18
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
20
 
@@ -87,6 +88,15 @@ function missingArt(file, json) {
87
88
  join(sceneDir, '..', rel),
88
89
  ];
89
90
  if (candidates.some((p) => existsSync(p))) continue;
91
+ // A BARE PACKAGE SPECIFIER is a bundler import, not a path — and it is the
92
+ // form the assets skill teaches for built-in art
93
+ // (`incanto/assets/items/coin.png`). A scene had no way to say "this URL
94
+ // arrives from the bundler": a made-up placeholder (`$KNIGHT_URL`) got
95
+ // flagged here as art nobody copied, and an empty string — which the loop
96
+ // above still skips — is rejected outright by the loader. So the honest
97
+ // spelling was unwritable, and every 2D example that uses built-in art
98
+ // warned about art that works.
99
+ if (resolvesAsPackageAsset(url, sceneDir)) continue;
90
100
  out.push(
91
101
  `$${key} → ${url} is not in the project ` +
92
102
  `(looked in ${relativeish(root, join(root, 'public'))}, ${relativeish(root, root)}` +
@@ -96,6 +106,23 @@ function missingArt(file, json) {
96
106
  return out;
97
107
  }
98
108
 
109
+ /**
110
+ * Can this url be resolved as a file inside an installed package?
111
+ *
112
+ * `incanto/assets/items/coin.png` is what the bundler is handed and what the
113
+ * skills teach; if the package resolves it, the file is really there.
114
+ */
115
+ function resolvesAsPackageAsset(url, from) {
116
+ if (url.startsWith('.') || url.startsWith('/')) return false;
117
+ // A bare specifier: `pkg/path` or `@scope/pkg/path`.
118
+ if (!/^(@[^/]+\/)?[^@/][^/]*\//.test(url)) return false;
119
+ try {
120
+ return existsSync(createRequire(join(from, 'noop.js')).resolve(url));
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
99
126
  /** Nearest ancestor with a package.json, else the scene's own directory. */
100
127
  function projectRoot(from) {
101
128
  let dir = resolve(from);
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * incanto-new — scaffold a ready-to-run incanto game from a shipped template:
4
4
  *
5
- * bunx incanto-new my-game # Beacon Isle (3D flagship)
6
- * bunx incanto-new my-game --template tps-3d # third-person shooter
7
- * bunx incanto-new --list
5
+ * bunx incanto new my-game # Beacon Isle (3D flagship)
6
+ * bunx incanto new my-game --template tps-3d # third-person shooter
7
+ * bunx incanto new --list
8
8
  *
9
9
  * Copies the template, names the package after the directory, prints the
10
10
  * three commands that get you playing. Templates are real npm consumers —
@@ -19,11 +19,25 @@ const TEMPLATES_DIR = join(PKG, 'templates-app');
19
19
 
20
20
  const DESCRIPTIONS = {
21
21
  'beacon-isle-3d':
22
- '3D island action-adventure (the flagship): generated world, quest NPC, terrain-nav enemies, melee',
23
- 'village-quest-3d': '3D quest vignette: dialogue, scene transitions, patrol AI, sword combat',
22
+ 'island action-adventure (the 3D flagship): generated world, quest NPC, terrain-nav enemies, melee',
23
+ 'village-quest-3d': 'quest vignette: dialogue, scene transitions, patrol AI, sword combat',
24
24
  'tps-3d': 'third-person arena shooter: GLB soldier, hitscan rifle, enemy waves',
25
+ 'platformer-2d':
26
+ 'side-scrolling platformer (the 2D flagship): tilemap level, coyote-time jump, follow cam, coins',
27
+ 'star-survivor':
28
+ 'survivors-like: waves that never stop, auto-attack, upgrades, a clock to outlast',
25
29
  };
26
30
 
31
+ /**
32
+ * Which half of the engine a starter belongs to.
33
+ *
34
+ * The list was three templates, all 3D, printed in one alphabetical column —
35
+ * while the engine shipped a whole 2D half with its own nodes, physics,
36
+ * tilemaps and skill. Someone building a 2D game read that list and concluded
37
+ * there was nothing for them.
38
+ */
39
+ const DIMENSION = (name) => (name.endsWith('-2d') || name === 'star-survivor' ? '2d' : '3d');
40
+
27
41
  function listTemplates() {
28
42
  if (!existsSync(TEMPLATES_DIR)) return [];
29
43
  return readdirSync(TEMPLATES_DIR, { withFileTypes: true })
@@ -34,9 +48,17 @@ function listTemplates() {
34
48
 
35
49
  const argv = process.argv.slice(2);
36
50
  if (argv.includes('--list') || argv.includes('-l')) {
37
- for (const t of listTemplates()) {
38
- console.log(` ${t.padEnd(20)} ${DESCRIPTIONS[t] ?? ''}`);
51
+ const all = listTemplates();
52
+ for (const [dim, heading] of [
53
+ ['3d', '3D'],
54
+ ['2d', '2D'],
55
+ ]) {
56
+ const group = all.filter((t) => DIMENSION(t) === dim);
57
+ if (group.length === 0) continue;
58
+ console.log(`\n ${heading}`);
59
+ for (const t of group) console.log(` ${t.padEnd(20)} ${DESCRIPTIONS[t] ?? ''}`);
39
60
  }
61
+ console.log('\n bunx incanto new my-game --template <name> (default: beacon-isle-3d)\n');
40
62
  process.exit(0);
41
63
  }
42
64
 
@@ -78,9 +78,35 @@ if (args.behaviors) {
78
78
  try {
79
79
  mod = await import(pathToFileURL(resolve(args.behaviors)).href);
80
80
  } catch (e) {
81
+ const why = e?.message ?? String(e);
82
+ // NODE CANNOT IMPORT THE PATTERN WE TEACH. Every template's behaviors.ts
83
+ // does `import gameJson from './game.scene.json'` — the documented way to
84
+ // read your own scene — and node's type stripping refuses a JSON import
85
+ // without `with { type: 'json' }`. Vite is fine with it, bun is fine with
86
+ // it, and this bin's shebang is node, so `--behaviors` failed on the
87
+ // engine's own flagship template. Hand the work to bun rather than telling
88
+ // the author their file is wrong: it is not.
89
+ const nodeCannotReadIt = /import attribute|Unknown file extension/i.test(why);
90
+ if (nodeCannotReadIt && !process.versions.bun && !process.env.INCANTO_BUN_REEXEC) {
91
+ const { spawnSync } = await import('node:child_process');
92
+ const hasBun = spawnSync('bun', ['--version'], { stdio: 'ignore' }).status === 0;
93
+ if (hasBun) {
94
+ const here = fileURLToPath(import.meta.url);
95
+ const again = spawnSync('bun', [here, ...process.argv.slice(2)], {
96
+ stdio: 'inherit',
97
+ env: { ...process.env, INCANTO_BUN_REEXEC: '1' },
98
+ });
99
+ process.exit(again.status ?? 1);
100
+ }
101
+ }
81
102
  console.error(
82
- `could not load --behaviors '${args.behaviors}' (${e?.message ?? e}). ` +
83
- 'Pass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.',
103
+ `could not load --behaviors '${args.behaviors}' (${why}).` +
104
+ (nodeCannotReadIt
105
+ ? '\n\nThis is node refusing your file, not a problem with it: node cannot' +
106
+ "\nimport JSON from a .ts module without `with { type: 'json' }`, and every" +
107
+ '\nincanto template imports its scene that way. Run it with bun, which' +
108
+ '\nreads the file as written: `bunx incanto playtest <scene> --behaviors <file>`.'
109
+ : '\nPass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.'),
84
110
  );
85
111
  process.exit(1);
86
112
  }
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { existsSync, readdirSync, statSync } from 'node:fs';
23
- import { dirname, join } from 'node:path';
23
+ import { dirname, join, relative } from 'node:path';
24
24
  import { fileURLToPath, pathToFileURL } from 'node:url';
25
25
 
26
26
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -102,7 +102,34 @@ if (!existsSync(scene) || !statSync(scene).isFile()) {
102
102
  process.exit(1);
103
103
  }
104
104
 
105
- const behaviors = flag('--behaviors');
105
+ /**
106
+ * Find the file that holds the game's Behavior subclasses.
107
+ *
108
+ * The scene was auto-discovered from the start; the behaviours were not, and
109
+ * without them a playtest runs the STRUCTURE with every script stubbed. On the
110
+ * engine's own flagship template that means the quest never advances, so bare
111
+ * `incanto-verify` — the exact command the README prints — reported `plays` as
112
+ * a failure and told the reader to re-run a different tool by hand, with the
113
+ * path to a file sitting right next to the scene it had already found.
114
+ */
115
+ function findBehaviors(sceneFile) {
116
+ const named = flag('--behaviors');
117
+ if (named) return { file: named, guessed: false };
118
+ const near = dirname(sceneFile);
119
+ for (const dir of [near, join(near, '..'), process.cwd(), join(process.cwd(), 'src')]) {
120
+ for (const base of ['behaviors', 'behaviours']) {
121
+ for (const ext of ['.ts', '.js', '.mjs']) {
122
+ const candidate = join(dir, base + ext);
123
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
124
+ return { file: candidate, guessed: true };
125
+ }
126
+ }
127
+ }
128
+ }
129
+ return { file: null, guessed: false };
130
+ }
131
+
132
+ const { file: behaviors, guessed: guessedBehaviors } = findBehaviors(scene);
106
133
  const rungs = [];
107
134
 
108
135
  // ---- loads ---------------------------------------------------------------
@@ -145,25 +172,61 @@ if (rungs[0].status === 'pass') {
145
172
  // walkabout template has no end, and calling that a failure sends its author
146
173
  // hunting a bug that was never there.
147
174
  const noGoal = out && out.declaresWin === false;
175
+ // A quest — talk to the NPC, clear the enemies, light the wards — cannot be
176
+ // finished by a random walker, ever. Reporting that as a FAILED rung means
177
+ // the headline command permanently says NOT verified about a correct game,
178
+ // which teaches its author to stop reading it. If every run played to the
179
+ // end of its budget without erroring, falling or wedging, the rung has not
180
+ // failed: it has not measured, and the author's own scripted harness is what
181
+ // can judge this game.
182
+ // Three of the five outcomes are GAMEPLAY, not defects. `won`, `lost` and
183
+ // `unfinished` all mean the game ran; a random player dying half the time in
184
+ // a platformer is the hazards working. The defects are `error` (a behaviour
185
+ // threw), `fell` (left the world) and `stuck` (went nowhere), and those are
186
+ // what this rung is for.
187
+ const PLAYED = new Set(['won', 'lost', 'unfinished']);
188
+ const playedOut =
189
+ out && (out.runs?.length ?? 0) > 0 && out.runs.every((x) => PLAYED.has(x.outcome));
190
+ const tally = (name) => out?.runs?.filter((x) => x.outcome === name).length ?? 0;
148
191
  rungs.push(
149
192
  r.status === 0
150
193
  ? { name: 'plays', status: 'pass', summary: `${won} of ${total} seeded runs finished it` }
151
- : noGoal
194
+ : playedOut
152
195
  ? {
153
196
  name: 'plays',
154
197
  status: 'unmeasured',
155
- summary: `nothing declares a win — ${total} runs played without error, and there was no end to reach`,
156
- fix: 'if it is meant to be finishable, emit `won` (GameFlow, ScoreKeeper, or your own behaviour)',
157
- }
158
- : {
159
- name: 'plays',
160
- status: 'fail',
161
198
  summary:
162
- total > 0 ? `no run finished it (${total} tried)` : 'the playtest could not run',
163
- fix: behaviors
164
- ? `see which runs stalled and where: \`incanto-playtest ${scene} --behaviors ${behaviors}\``
165
- : `run it with your behaviours — without them the structure plays and your game logic does not: \`incanto-playtest ${scene} --behaviors src/behaviors.ts\``,
166
- },
199
+ `${total} runs played without reaching a win` +
200
+ ` (${[
201
+ tally('lost') && `${tally('lost')} lost`,
202
+ tally('unfinished') && `${tally('unfinished')} ran out the clock`,
203
+ ]
204
+ .filter(Boolean)
205
+ .join(', ')})`,
206
+ fix: 'nothing here is broken — a win that takes skill or a sequence is out of reach of random play. Judge it with a scripted run: `bun run verify`, or `runScript` from `incanto/test`',
207
+ }
208
+ : noGoal
209
+ ? {
210
+ name: 'plays',
211
+ status: 'unmeasured',
212
+ summary: `nothing declares a win — ${total} runs played without error, and there was no end to reach`,
213
+ fix: 'if it is meant to be finishable, emit `won` (GameFlow, ScoreKeeper, or your own behaviour)',
214
+ }
215
+ : {
216
+ name: 'plays',
217
+ status: 'fail',
218
+ summary:
219
+ total > 0
220
+ ? `no run finished it (${total} tried)`
221
+ : // "could not run" with no reason is the tool doing to its
222
+ // reader exactly what this whole ladder exists to prevent:
223
+ // reporting a failure it already knows the cause of. The
224
+ // child printed one; pass it on.
225
+ `the playtest could not run — ${firstLine(r.stderr) || `exit ${r.status}`}`,
226
+ fix: behaviors
227
+ ? `see which runs stalled and where: \`incanto-playtest ${scene} --behaviors ${behaviors}\``
228
+ : `run it with your behaviours — without them the structure plays and your game logic does not: \`incanto-playtest ${scene} --behaviors src/behaviors.ts\``,
229
+ },
167
230
  );
168
231
  } else {
169
232
  rungs.push({ name: 'plays', status: 'skipped', summary: 'not run — the scene does not load' });
@@ -298,7 +361,16 @@ function firstLine(text) {
298
361
  }
299
362
 
300
363
  const verdict = ladderVerdict(rungs);
301
- console.log(asJson ? JSON.stringify({ scene, ...verdict }, null, 2) : ladderText(verdict));
364
+ if (asJson) {
365
+ console.log(JSON.stringify({ scene, behaviors, ...verdict }, null, 2));
366
+ } else {
367
+ if (guessedBehaviors) {
368
+ console.log(
369
+ `· behaviours: ${relative(process.cwd(), behaviors) || behaviors} (found, not named)`,
370
+ );
371
+ }
372
+ console.log(ladderText(verdict));
373
+ }
302
374
  // `exitCode`, never `process.exit()`: stdout to a PIPE is written
303
375
  // asynchronously, and exiting discards whatever has not flushed. A --json
304
376
  // report read by another program came back truncated — silently, and only
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * incanto — the front door.
4
+ *
5
+ * bunx incanto new my-game scaffold a game (no install needed first)
6
+ * bunx incanto check src validate scene JSON
7
+ * bunx incanto verify the whole verification ladder
8
+ *
9
+ * WHY THIS EXISTS. Every doc used to open with `bunx incanto-new my-game`, and
10
+ * that command 404s for the one person it is written for. `bunx <name>` fetches
11
+ * the PACKAGE called `<name>`; it does not search the registry for a package
12
+ * that happens to ship a bin by that name. There is no `incanto-new` package,
13
+ * so a brand-new reader's very first command failed — while every existing
14
+ * consumer ran it happily, because with `incanto` already in `node_modules` the
15
+ * same words resolve to the local bin. The funnel was the one path nobody
16
+ * walked.
17
+ *
18
+ * `bunx incanto <subcommand>` works from nothing, because `incanto` IS the
19
+ * package name. The `incanto-*` bins all still exist and are unchanged — inside
20
+ * a project they are the shorter thing to type.
21
+ *
22
+ * Dispatch is an in-process import, not a spawn: the child bin then runs under
23
+ * the SAME runtime that started this one, which matters because `--behaviors`
24
+ * loads your TypeScript and bun and node disagree about what they can import.
25
+ */
26
+ import { readFileSync } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
28
+ import { fileURLToPath, pathToFileURL } from 'node:url';
29
+
30
+ const HERE = dirname(fileURLToPath(import.meta.url));
31
+
32
+ /** Subcommand → bin file, in the order the help should list them. */
33
+ const COMMANDS = {
34
+ new: ['incanto-new.mjs', 'scaffold a ready-to-run game from a template'],
35
+ check: ['incanto-check.mjs', 'validate scene JSON and the assets it names'],
36
+ verify: ['incanto-verify.mjs', 'the whole ladder: loads · plays · feels · draws · says'],
37
+ playtest: ['incanto-playtest.mjs', 'drive the game headlessly and report what happened'],
38
+ play: ['incanto-play.mjs', 'run a scene headlessly for a fixed span'],
39
+ feel: ['incanto-feel.mjs', 'measure jump height, run speed, facing'],
40
+ frame: ['incanto-frame.mjs', 'what the running page is drawing right now'],
41
+ logs: ['incanto-logs.mjs', 'what the running page has been saying'],
42
+ editor: ['incanto-editor.mjs', 'the visual scene composer'],
43
+ model: ['incanto-model.mjs', "a GLB's real bounds, animations and rig"],
44
+ assets: ['incanto-assets.mjs', 'the built-in art and audio catalog'],
45
+ env: ['incanto-env.mjs', 'generate a 3D environment'],
46
+ skills: ['incanto-skills.mjs', 'install the agent skills for this engine version'],
47
+ };
48
+
49
+ function version() {
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf-8'));
52
+ return pkg.version ?? 'unknown';
53
+ } catch {
54
+ return 'unknown';
55
+ }
56
+ }
57
+
58
+ function help() {
59
+ const width = Math.max(...Object.keys(COMMANDS).map((k) => k.length));
60
+ const lines = Object.entries(COMMANDS).map(
61
+ ([name, [, blurb]]) => ` ${name.padEnd(width)} ${blurb}`,
62
+ );
63
+ return [
64
+ `incanto ${version()} — a vibe-coding-first web game engine`,
65
+ '',
66
+ 'Usage: bunx incanto <command> [options]',
67
+ '',
68
+ ...lines,
69
+ '',
70
+ 'Start a game: bunx incanto new my-game',
71
+ 'See the templates: bunx incanto new --list',
72
+ '',
73
+ 'Each command also ships as its own bin (`incanto-new`, `incanto-check`, …),',
74
+ 'which is what to type once the package is installed in your project.',
75
+ ].join('\n');
76
+ }
77
+
78
+ const [command, ...rest] = process.argv.slice(2);
79
+
80
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
81
+ console.log(help());
82
+ process.exit(0);
83
+ }
84
+ if (command === '--version' || command === '-v') {
85
+ console.log(version());
86
+ process.exit(0);
87
+ }
88
+
89
+ const entry = COMMANDS[command];
90
+ if (!entry) {
91
+ const near = Object.keys(COMMANDS).filter((k) => k.startsWith(command[0] ?? ''));
92
+ console.error(
93
+ `incanto: unknown command '${command}'.` +
94
+ (near.length ? ` Did you mean: ${near.join(', ')}?` : '') +
95
+ '\n\n' +
96
+ help(),
97
+ );
98
+ process.exit(2);
99
+ }
100
+
101
+ const target = join(HERE, entry[0]);
102
+ // The child reads `process.argv.slice(2)`, so hand it an argv that looks like
103
+ // it was invoked directly. Importing rather than spawning keeps one process
104
+ // and, more importantly, one runtime.
105
+ process.argv = [process.argv[0], target, ...rest];
106
+ await import(pathToFileURL(target).href);
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-DpbUrMOQ.js";
2
+ import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-BZwWJIns.js";
3
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-C6ZBFRjd.js";
4
- import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-BLcvEFDR.js";
4
+ import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-EqA-jddf.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
package/dist/3d.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { a as frameSignature, n as diffSignatures, o as frameStats, r as diffText, s as frameText, t as SIGNATURE_GRID } from "./frame-report-BSMny7oe.js";
2
2
  import { B as StaticBody3D, F as WaterCutout3D, I as Area3D, L as CharacterBody3D, N as Water3D, P as WATER_CUTOUT_MAX, R as PhysicsBody3D, V as Node3D, W as WATER_MAX_RIPPLES, z as RigidBody3D } from "./gameplay-BVphcxmE.js";
3
3
  import { A as Terrain3D, B as keyboardIntensity, C as resolveFlowerDensity, D as BoneLookAt3D, E as Camera3D, F as InstancedMesh3D, G as acquireTexture, H as rigPose, I as MeshInstance3D, M as TERRAIN_THEMES, N as terrainThemeLayers, O as BoneAttachment3D, P as Joint3D, R as QUARTER_PITCH, S as Flowers3D, T as CharacterController3D, U as TextureCache3D, V as movementState, W as acquireOwnTexture, _ as LoftMesh3D, a as Tree3D, b as Foliage3D, c as buildRiverRings, d as riverCarveChannels, f as riverStepFor, g as ModelInstance3D, h as Particles3D, i as VoxelGrid3D, j as DEFAULT_TERRAIN_TEXTURE_BASE, k as Billboard3D, l as findRiverCoverageGaps, m as traceDownhillPath, n as registerNodes3D, o as Trail3D, p as smoothCourse, r as VOXEL_PALETTE, s as River3D, u as projectToRiver, v as DirectionalLight3D, w as FLOWER_VARIETIES, x as DENSITY_PRESETS, y as OmniLight3D, z as cameraRelative } from "./environment-presets-CvvQr_bJ.js";
4
- import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-C5jQYPah.js";
4
+ import { a as Environment3D, c as parseEnvironment3D, d as AssetStore3D, i as syncTree, l as sunDirectionFromElevationAzimuth, o as setEnvironment3D, r as Renderer3D, s as horizonColorFromSky, t as createGame3D, u as sunDirectionFromSky } from "./create-game-D10bU5_J.js";
5
5
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
6
- import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-QBrfIT2Y.js";
6
+ import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-Dnz4fsXX.js";
7
7
  //#region src/3d/model-verdict.ts
8
8
  /** Mixamo exports every bone as `mixamorigX`; the retargeter binds by that name. */
9
9
  const MIXAMO = /^mixamorig[:_]?/i;
@@ -8,7 +8,7 @@ import { o as frameStats } from "./frame-report-BSMny7oe.js";
8
8
  import { n as registerGameplayBehaviors } from "./gameplay-BVphcxmE.js";
9
9
  import { g as PhysicsBody2D, n as UILayer, t as registerNodes2D, u as Camera2D, y as Node2D } from "./register-C6ZBFRjd.js";
10
10
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
11
- import { n as enablePhysics2D } from "./physics-2d-BLcvEFDR.js";
11
+ import { n as enablePhysics2D } from "./physics-2d-EqA-jddf.js";
12
12
  import { Box3, BufferAttribute, BufferGeometry, Color, LineBasicMaterial, LineSegments, LinearFilter, NearestFilter, OrthographicCamera, Raycaster, SRGBColorSpace, Scene, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three";
13
13
  //#region src/2d/assets.ts
14
14
  /**
@@ -8,7 +8,7 @@ import { o as frameStats } from "./frame-report-BSMny7oe.js";
8
8
  import { R as PhysicsBody3D, U as createCausticsQuad, V as Node3D, n as registerGameplayBehaviors } from "./gameplay-BVphcxmE.js";
9
9
  import { t as debugSources } from "./debug-draw-BM3DsvtT.js";
10
10
  import { E as Camera3D, U as TextureCache3D, g as ModelInstance3D, n as registerNodes3D, t as resolveEnvironmentHdri, v as DirectionalLight3D } from "./environment-presets-CvvQr_bJ.js";
11
- import { n as enablePhysics3D } from "./physics-3d-QBrfIT2Y.js";
11
+ import { n as enablePhysics3D } from "./physics-3d-Dnz4fsXX.js";
12
12
  import { ACESFilmicToneMapping, AmbientLight, Box3, BufferAttribute, BufferGeometry, Color, DepthTexture, EquirectangularReflectionMapping, FloatType, Fog, HalfFloatType, LineBasicMaterial, LineSegments, Matrix4, Mesh, PCFShadowMap, PMREMGenerator, PerspectiveCamera, PlaneGeometry, Quaternion, Raycaster, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderTarget, WebGLRenderer } from "three";
13
13
  import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
14
14
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
@@ -173,6 +173,59 @@ var AssetStore3D = class {
173
173
  }
174
174
  };
175
175
  //#endregion
176
+ //#region src/core/yield-frame.ts
177
+ /**
178
+ * Give the browser a chance to paint, and come back either way.
179
+ *
180
+ * Boot work that runs in slices — compiling shaders a few materials at a time,
181
+ * reporting progress — yields between slices so the page stays responsive and
182
+ * the loading bar can move. The obvious way to write that yield is
183
+ * `requestAnimationFrame`, and it is a trap: **a browser does not run rAF for
184
+ * a tab it considers hidden**, and "hidden" includes a background tab, a
185
+ * minimized window, and a window fully covered by another one. Not slowly —
186
+ * not at all. An `await` on rAF alone in that tab never returns.
187
+ *
188
+ * That is what happened to `createGame3D`: the shader warm-up hung mid-boot,
189
+ * so the promise never resolved, the game's own `#loading` overlay was never
190
+ * removed, and the dev channel — wired on the line after the one that hung —
191
+ * never answered `incanto-frame` or `incanto-logs`. `incanto-verify` reported
192
+ * `draws` and `says` as unmeasurable and told the reader to bring the window
193
+ * to the front, which was true but sounded like a browser limitation. It was
194
+ * ours, and it made two of the five verification rungs unreachable to anything
195
+ * that cannot focus a window.
196
+ *
197
+ * So: race the frame against a timer. When frames are coming the frame wins
198
+ * (~16 ms) and the behaviour is what it always was. When none is coming the
199
+ * timer wins and the boot finishes — slower, because a hidden tab also clamps
200
+ * timers to about a second, but finished beats hung.
201
+ */
202
+ /** How long to wait for a paint that may never come. */
203
+ const DEFAULT_MAX_WAIT_MS = 50;
204
+ /**
205
+ * Resolve on the next painted frame, or after `maxWaitMs`, whichever is first.
206
+ *
207
+ * @param maxWaitMs how long to wait for a frame before giving up on one
208
+ * @param raf injected for tests; defaults to the global
209
+ */
210
+ function yieldToFrame(maxWaitMs = DEFAULT_MAX_WAIT_MS, raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : void 0) {
211
+ return new Promise((resolve) => {
212
+ let done = false;
213
+ const finish = () => {
214
+ if (done) return;
215
+ done = true;
216
+ resolve();
217
+ };
218
+ const timer = setTimeout(finish, Math.max(0, maxWaitMs));
219
+ if (!raf) return;
220
+ raf(() => {
221
+ setTimeout(() => {
222
+ clearTimeout(timer);
223
+ finish();
224
+ }, 0);
225
+ });
226
+ });
227
+ }
228
+ //#endregion
176
229
  //#region src/3d/environment.ts
177
230
  const SKY_KEYS = [
178
231
  "type",
@@ -1804,9 +1857,7 @@ var Renderer3D = class {
1804
1857
  for (let k = i * per; k < Math.min((i + 1) * per, hidden.length); k++) hidden[k].visible = true;
1805
1858
  this.webgl.compile(this.threeScene, camera);
1806
1859
  onProgress?.((i + 1) / slices);
1807
- if (typeof requestAnimationFrame === "function") await new Promise((resolve) => {
1808
- requestAnimationFrame(() => setTimeout(resolve, 0));
1809
- });
1860
+ await yieldToFrame();
1810
1861
  }
1811
1862
  } finally {
1812
1863
  for (const obj of hidden) obj.visible = true;
@@ -2333,10 +2384,7 @@ async function createGame3D(opts) {
2333
2384
  const report = async (fraction, label) => {
2334
2385
  if (!opts.onProgress) return;
2335
2386
  opts.onProgress(fraction, label);
2336
- if (typeof requestAnimationFrame !== "function") return;
2337
- await new Promise((resolve) => {
2338
- requestAnimationFrame(() => setTimeout(resolve, 0));
2339
- });
2387
+ await yieldToFrame();
2340
2388
  };
2341
2389
  registerNodes3D();
2342
2390
  if (opts.gameplay ?? true) registerGameplayBehaviors();
package/dist/index.js CHANGED
@@ -8,6 +8,6 @@ import { a as nodeRefWarnings, i as describeRefProblem, n as startRecording, o a
8
8
  import { a as logReport, i as resolveRendering, n as attachTouchControls, o as logText, r as joystickVector, s as parseDrive, t as TouchControls } from "./touch-DESwnpOc.js";
9
9
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
10
10
  import { a as PARTICLE_PRESETS, i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-C0wXLBZJ.js";
11
- import { a as preloadUrls, i as preloadSceneAssets, n as newUid, o as findPath, r as assetUrls, s as gridFromRows, t as VERSION } from "./src-CGjmPw65.js";
11
+ import { a as preloadUrls, i as preloadSceneAssets, n as newUid, o as findPath, r as assetUrls, s as gridFromRows, t as VERSION } from "./src-C1J09Op6.js";
12
12
  import { t as duplicateNode } from "./duplicate-BPLZDZpd.js";
13
13
  export { AudioBuses, AudioPlayer, BASE_LOCALE, Behavior, CONST_REF_KEY, EffectLog, Engine, HudLayer, IncantoError, InputMap, Localization, LogManager, MusicManager, Node, ORDER_GROUP_BASE, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, ParticleSim, ROLLOFF_MODELS, Rng, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, SaveSlots, Scene, SceneTree, Settings, SfxEngine, Signal, T_PREFIX, Timer, TouchControls, UiBanner, UiBar, UiButton, UiDialogue, UiFrameCapSelect, UiImage, UiLanguageSelect, UiMuteToggle, UiPanel, UiQualitySelect, UiRenderScaleSelect, UiSelect, UiSlider, UiText, UiToggle, UiVolumeSlider, VERSION, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, behaviorSchema, behaviorSignals, behaviorsWithoutSave, captureBehaviors, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, describeRefProblem, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, logReport, logText, mergeStaticSignals, newUid, nodeRefWarnings, parseDrive, parseNodePath, preloadSceneAssets, preloadUrls, qualityEnvironment, readDeviceHints, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveAnimation, resolveConstants, resolveFrames, resolveOrderGroups, resolveRefInJson, resolveRendering, resolveViewport, restoreBehaviors, savesWithoutUid, serializeNode, spatialGain, spatialPan, startRecording, suggestLocale, suggestQuality, synthSfx, translationKey };
@@ -3,6 +3,7 @@ import { C as diagnose } from "./loader-BcrRSjxB.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { _ as RigidBody2D, b as validateCollider2D, g as PhysicsBody2D, h as CharacterBody2D, m as Area2D, p as Joint2D, y as Node2D } from "./register-C6ZBFRjd.js";
5
5
  import { n as registerDebugSource } from "./debug-draw-BM3DsvtT.js";
6
+ import { t as withoutRapierInitNoise } from "./quiet-rapier-BAJ4K94N.js";
6
7
  //#region src/2d/physics/physics-2d.ts
7
8
  var physics_2d_exports = /* @__PURE__ */ __exportAll({
8
9
  Physics2D: () => Physics2D,
@@ -17,7 +18,7 @@ const DEG = Math.PI / 180;
17
18
  */
18
19
  async function enablePhysics2D(engine, opts) {
19
20
  const R = await import("@dimforge/rapier2d-compat");
20
- await R.init({});
21
+ await withoutRapierInitNoise(() => R.init());
21
22
  return new Physics2D(R, engine, opts);
22
23
  }
23
24
  /**
@@ -3,6 +3,7 @@ import { C as diagnose } from "./loader-BcrRSjxB.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { H as validateCollider3D, I as Area3D, L as CharacterBody3D, R as PhysicsBody3D, V as Node3D, z as RigidBody3D } from "./gameplay-BVphcxmE.js";
5
5
  import { n as registerDebugSource } from "./debug-draw-BM3DsvtT.js";
6
+ import { t as withoutRapierInitNoise } from "./quiet-rapier-BAJ4K94N.js";
6
7
  import { A as Terrain3D, F as InstancedMesh3D, L as buildMeshGeometry, P as Joint3D } from "./environment-presets-CvvQr_bJ.js";
7
8
  import { Euler, Matrix4, Quaternion, Vector3 } from "three";
8
9
  //#region src/3d/physics/collider-lines.ts
@@ -215,7 +216,7 @@ const DEG = Math.PI / 180;
215
216
  */
216
217
  async function enablePhysics3D(engine, opts) {
217
218
  const R = await import("@dimforge/rapier3d-compat");
218
- await R.init({});
219
+ await withoutRapierInitNoise(() => R.init());
219
220
  return new Physics3D(R, engine, opts);
220
221
  }
221
222
  /** Per-engine 3D physics world. Same contract as Physics2D. */
@@ -0,0 +1,46 @@
1
+ //#region src/core/quiet-rapier.ts
2
+ /**
3
+ * Swallow one wrong warning that rapier prints about itself.
4
+ *
5
+ * `@dimforge/rapier*-compat`'s `init()` takes NO arguments; it loads the wasm
6
+ * from a base64 blob and calls the generated `__wbg_init(buffer)` with an
7
+ * `ArrayBuffer`. That generated function only accepts a plain object now, so
8
+ * it prints
9
+ *
10
+ * using deprecated parameters for the initialization function;
11
+ * pass a single object instead
12
+ *
13
+ * on every single boot — in the browser console a new reader opens first, and
14
+ * in the output of every headless `bun run verify`. There is nothing anyone
15
+ * can do about it: it is rapier warning rapier. The engine used to pass `{}`
16
+ * to `init` with a comment saying that avoided it. It did not — the argument
17
+ * is ignored, `init()` declares no parameters — and the comment made the noise
18
+ * look handled for several releases.
19
+ *
20
+ * A warning its reader cannot act on is worse than no warning: it teaches them
21
+ * that warnings from this engine are furniture. So it is silenced, narrowly —
22
+ * one exact message, only across the await, and only on `console.warn`. Every
23
+ * other thing rapier has to say still gets through.
24
+ */
25
+ /** The one message, verbatim from `rapier_wasm3d.js` / `rapier_wasm2d.js`. */
26
+ const NOISE = "using deprecated parameters for the initialization function";
27
+ /**
28
+ * Run `boot` with rapier's self-directed deprecation warning suppressed.
29
+ *
30
+ * @param boot the `init()` call to make quiet
31
+ * @param out injected for tests; defaults to the global console
32
+ */
33
+ async function withoutRapierInitNoise(boot, out = console) {
34
+ const original = out.warn;
35
+ out.warn = (...args) => {
36
+ if (typeof args[0] === "string" && args[0].includes(NOISE)) return;
37
+ original.apply(out, args);
38
+ };
39
+ try {
40
+ await boot();
41
+ } finally {
42
+ out.warn = original;
43
+ }
44
+ }
45
+ //#endregion
46
+ export { withoutRapierInitNoise as t };