incanto 0.68.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/incanto-assets.mjs +14 -1
  3. package/bin/incanto-check.mjs +53 -3
  4. package/bin/incanto-new.mjs +29 -1
  5. package/bin/incanto-serve.mjs +160 -0
  6. package/bin/incanto-verify.mjs +40 -0
  7. package/bin/incanto.mjs +1 -0
  8. package/dist/2d.d.ts +7 -5
  9. package/dist/2d.js +4 -4
  10. package/dist/3d.d.ts +4 -4
  11. package/dist/3d.js +7 -7
  12. package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-C0j2k-yB.d.ts} +32 -13
  13. package/dist/{behavior-DoFPYrgo.d.ts → behavior-rm8vVzu_.d.ts} +67 -0
  14. package/dist/{create-game-DbWtVTxD.js → create-game-CvJ1wSRP.js} +13 -9
  15. package/dist/{create-game-IZIydDwI.js → create-game-CwxEDlCm.js} +13 -9
  16. package/dist/debug.d.ts +1 -1
  17. package/dist/{duplicate-IWIqk0HJ.js → duplicate-Dj4L-5AA.js} +1 -1
  18. package/dist/editor.js +1 -1
  19. package/dist/{environment-presets-CybQXNqS.js → environment-presets-BO-7XoFV.js} +4 -4
  20. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  21. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  22. package/dist/{gameplay-DM1eu_cV.js → gameplay-D1KixWK8.js} +157 -151
  23. package/dist/gameplay.d.ts +1 -1
  24. package/dist/gameplay.js +1 -1
  25. package/dist/index.d.ts +4 -4
  26. package/dist/index.js +6 -6
  27. package/dist/{loader-BC4PNtJX.js → loader-DZE7B07H.js} +22 -6
  28. package/dist/{loader-CcB533FR.d.ts → loader-DadV2QCT.d.ts} +1 -1
  29. package/dist/net.d.ts +2 -2
  30. package/dist/net.js +1 -1
  31. package/dist/{physics-2d-B7Y6dPZO.js → physics-2d-CF5yyjbj.js} +2 -2
  32. package/dist/{physics-3d-bG3n70Ky.js → physics-3d-DbYaEcZy.js} +3 -3
  33. package/dist/react.d.ts +1 -1
  34. package/dist/react.js +1 -1
  35. package/dist/{register-ibCjm-wH.js → register-DbVArsns.js} +19 -10
  36. package/dist/{register-3ta-2Xig.js → register-mmfugpaQ.js} +107 -15
  37. package/dist/{replay-CYvhVHHN.js → replay-BEHLDcQV.js} +2 -2
  38. package/dist/{replay-Dvn8aeBd.d.ts → replay-BQfkX4x7.d.ts} +1 -1
  39. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  40. package/dist/{split-screen-CYwDkbLF.js → split-screen-CauMwu6y.js} +2 -2
  41. package/dist/{split-screen-DhrSzZIB.d.ts → split-screen-Cg76MCV5.d.ts} +2 -2
  42. package/dist/{src-D7RIqXYF.js → src-CYEu-_S9.js} +1 -1
  43. package/dist/{teardown-D2NEmxPB.js → teardown-CMqFVxO9.js} +5 -4
  44. package/dist/{test-8hoHeRmo.js → test-D6ISmuFE.js} +60 -17
  45. package/dist/test.d.ts +15 -4
  46. package/dist/test.js +2 -2
  47. package/dist/vite.d.ts +70 -2
  48. package/dist/vite.js +176 -14
  49. package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-Z5_g8x9H.js} +12 -1
  50. package/editor/assets/{agent8-BrrHOjMJ.js → agent8-BDvaZMQJ.js} +1 -1
  51. package/editor/assets/{debug-BBhKuBNV.js → debug-CMULinBW.js} +1 -1
  52. package/editor/assets/{index-eVd0BToA.js → index-CZIRr5EL.js} +91 -91
  53. package/editor/index.html +1 -1
  54. package/package.json +4 -3
  55. package/skills/incanto-assets.md +16 -2
  56. package/skills/incanto-audio.md +19 -3
  57. package/skills/incanto-building-2d-games.md +29 -0
  58. package/skills/incanto-building-3d-games.md +60 -2
  59. package/skills/incanto-gameplay-behaviors.md +18 -1
  60. package/skills/incanto-save-slots.md +27 -0
  61. package/skills/incanto-verifying-your-game.md +96 -2
  62. package/skills/incanto-your-first-game.md +2 -1
  63. package/templates-app/beacon-isle-3d/package.json +1 -1
  64. package/templates-app/beacon-isle-3d/src/main.ts +13 -3
  65. package/templates-app/platformer-2d/index.html +0 -23
  66. package/templates-app/platformer-2d/package.json +1 -1
  67. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  68. package/templates-app/platformer-2d/src/game.scene.json +17 -27
  69. package/templates-app/platformer-2d/src/main.ts +24 -12
  70. package/templates-app/star-survivor/package.json +1 -1
  71. package/templates-app/star-survivor/src/main.ts +17 -6
  72. package/templates-app/tps-3d/package.json +1 -1
  73. package/templates-app/tps-3d/src/main.ts +26 -15
  74. package/templates-app/village-quest-3d/package.json +1 -1
  75. package/templates-app/village-quest-3d/src/main.ts +13 -3
@@ -5,7 +5,7 @@
5
5
  "url": "incanto/assets/characters/2dbasic.png",
6
6
  "kind": "character",
7
7
  "bytes": 30499,
8
- "description": "2dbasic sprite sheet image.anything,base character. (frame size 192x192)",
8
+ "description": "2dbasic sprite sheet image.anything,base character.",
9
9
  "animation": "characters/2dbasic.json",
10
10
  "frameWidth": 111,
11
11
  "frameHeight": 83
@@ -144,7 +144,7 @@
144
144
  "url": "incanto/assets/characters/ghost.png",
145
145
  "kind": "character",
146
146
  "bytes": 22933,
147
- "description": "Ghost character with translucent appearance sprite sheet image (frame size 112x128)",
147
+ "description": "Ghost character with translucent appearance sprite sheet image",
148
148
  "animation": "characters/ghost.json",
149
149
  "frameWidth": 112,
150
150
  "frameHeight": 128
@@ -155,7 +155,7 @@
155
155
  "url": "incanto/assets/characters/goblin.png",
156
156
  "kind": "character",
157
157
  "bytes": 57994,
158
- "description": "Medieval goblin with torch sprite sheet image (frame size 192x192)",
158
+ "description": "Medieval goblin with torch sprite sheet image",
159
159
  "animation": "characters/goblin.json",
160
160
  "frameWidth": 192,
161
161
  "frameHeight": 192
@@ -286,7 +286,7 @@
286
286
  "url": "incanto/assets/characters/medieval-knight.png",
287
287
  "kind": "character",
288
288
  "bytes": 84367,
289
- "description": "(frame size 192x192) Using a medieval-themed SD(Super Deformed) knight sprite sheet image, you can apply idle, move, and attack animations, among others.",
289
+ "description": "Using a medieval-themed SD(Super Deformed) knight sprite sheet image, you can apply idle, move, and attack animations, among others.",
290
290
  "animation": "characters/medieval-knight.json",
291
291
  "frameWidth": 192,
292
292
  "frameHeight": 192
@@ -297,7 +297,11 @@
297
297
  "url": "incanto/assets/tiles/minecraft-tiles.png",
298
298
  "kind": "tile",
299
299
  "bytes": 10511,
300
- "description": "Minecraft-themed tiles sprite sheet image (frame size 16x16)"
300
+ "description": "Minecraft-themed tiles sprite sheet image",
301
+ "frameWidth": 16,
302
+ "frameHeight": 16,
303
+ "columns": 5,
304
+ "tiles": 25
301
305
  },
302
306
  {
303
307
  "name": "monster-died",
@@ -40,8 +40,21 @@ if (command === 'list') {
40
40
  } else {
41
41
  for (const e of entries) {
42
42
  const anim = e.animation ? ' [animated]' : '';
43
+ /*
44
+ * The GRID, from the fields rather than the prose.
45
+ *
46
+ * `list` printed only the description, and a description carrying a frame
47
+ * size is a second source of truth that drifts: `2dbasic` said
48
+ * "(frame size 192x192)" against a real grid of 111x83 — 666x664 does not
49
+ * divide by 192 — and the wrong number was the first one an agent read.
50
+ * The sizes live in `frameWidth`/`frameHeight` now, and the prose says
51
+ * what the art IS.
52
+ */
53
+ const grid = e.frameWidth
54
+ ? ` ${e.frameWidth}×${e.frameHeight}${e.tiles ? ` (${e.tiles} tiles)` : ''}`
55
+ : '';
43
56
  console.log(
44
- `${e.name.padEnd(20)} ${e.kind.padEnd(10)}${anim.padEnd(11)} ${e.description.slice(0, 80)}`,
57
+ `${e.name.padEnd(20)} ${e.kind.padEnd(10)}${anim.padEnd(11)}${grid.padEnd(16)} ${e.description.slice(0, 64)}`,
45
58
  );
46
59
  }
47
60
  console.log(`\n${entries.length} built-in asset(s) — incanto-assets info <name> for details`);
@@ -18,14 +18,27 @@ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node
18
18
  import { createRequire } from 'node:module';
19
19
  import { dirname, join, resolve } from 'node:path';
20
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
+ import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
21
22
 
22
23
  const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
23
24
 
24
25
  function parseArgs(argv) {
25
- const args = { files: [], json: false, strict: false, help: false, invalid: false };
26
- for (const a of argv) {
26
+ const args = {
27
+ files: [],
28
+ json: false,
29
+ strict: false,
30
+ help: false,
31
+ invalid: false,
32
+ behaviors: null,
33
+ };
34
+ // Indexed, because `--behaviors` takes the NEXT argument. A `for…of` with a
35
+ // hand-rolled `++i` reads fine and throws `i is not defined` at runtime,
36
+ // which is a flag that exists in the help and cannot be used.
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
27
39
  if (a === '--json') args.json = true;
28
40
  else if (a === '--strict-behaviors') args.strict = true;
41
+ else if (a === '--behaviors') args.behaviors = argv[++i];
29
42
  else if (a === '--strict-warnings') args.strictWarnings = true;
30
43
  else if (a === '--help' || a === '-h') args.help = true;
31
44
  else if (a.startsWith('--')) {
@@ -38,7 +51,7 @@ function parseArgs(argv) {
38
51
 
39
52
  const args = parseArgs(process.argv.slice(2));
40
53
  if (args.help || args.invalid) {
41
- console.log(`Usage: npx incanto check [files|dirs...] [--json] [--strict-behaviors] [--strict-warnings]
54
+ console.log(`Usage: npx incanto check [files|dirs...] [--json] [--behaviors FILE] [--strict-behaviors] [--strict-warnings]
42
55
 
43
56
  Validates *.scene.json files headlessly with the installed engine.
44
57
  With no arguments, scans the current directory recursively
@@ -159,6 +172,30 @@ const { auditScene, instancedScenes, validateScene } = await import(
159
172
  pathToFileURL(join(DIST, 'test.js')).href
160
173
  );
161
174
 
175
+ /** Your Behavior subclasses, when you name a file — same contract as the other bins. */
176
+ const behaviors = {};
177
+ if (args.behaviors) {
178
+ const { Behavior } = await import(pathToFileURL(join(DIST, 'index.js')).href);
179
+ let mod;
180
+ try {
181
+ mod = await import(pathToFileURL(resolve(args.behaviors)).href);
182
+ } catch (e) {
183
+ const why = e?.message ?? String(e);
184
+ // node cannot import the pattern this package teaches (a JSON import
185
+ // without an attribute, an extensionless relative path); bun can. Same
186
+ // hand-off as `incanto-playtest`.
187
+ reexecUnderBun(import.meta.url, why);
188
+ console.error(behaviorsHelp(args.behaviors, why));
189
+ process.exit(1);
190
+ }
191
+ for (const [name, value] of Object.entries(mod)) {
192
+ if (typeof value === 'function' && value.prototype instanceof Behavior) behaviors[name] = value;
193
+ }
194
+ if (Object.keys(behaviors).length === 0) {
195
+ console.error(`warning: no Behavior subclasses exported from ${args.behaviors}`);
196
+ }
197
+ }
198
+
162
199
  /**
163
200
  * Every `strings` table in the corpus, unioned.
164
201
  *
@@ -211,6 +248,19 @@ const results = files.map((file) => {
211
248
  }
212
249
  const res = validateScene(json, {
213
250
  strictBehaviors: args.strict,
251
+ /*
252
+ * `validateScene` has always taken `behaviors`; this bin had no way to pass
253
+ * any. So `--strict-behaviors` could only ever FAIL on a project with a
254
+ * behaviour of its own — which is every project past its first hour:
255
+ *
256
+ * [UNKNOWN_BEHAVIOR] Unknown behavior 'Hunter'. … Did you forget
257
+ * registerBehavior('Hunter', Hunter)?
258
+ * 0/1 scene(s) valid EXIT=1
259
+ *
260
+ * A flag whose only outcome is failure is a flag nobody uses, and the check
261
+ * it gates — a script name that resolves nowhere — is worth having.
262
+ */
263
+ behaviors,
214
264
  declaredAssets,
215
265
  resolveScene: (p) => JSON.parse(readFileSync(resolve(dirname(file), p), 'utf-8')),
216
266
  });
@@ -70,7 +70,11 @@ for (let i = 0; i < argv.length; i++) {
70
70
  }
71
71
 
72
72
  if (!target) {
73
- console.error('usage: incanto-new <dir> [--template <name>] | --list');
73
+ console.error(
74
+ 'usage: incanto-new <dir> [--template <name>] [--local] | --list\n' +
75
+ ' --local pin `file:` to the incanto this command is running from,\n' +
76
+ ' instead of the registry — for verifying a release candidate.',
77
+ );
74
78
  process.exit(1);
75
79
  }
76
80
 
@@ -93,10 +97,30 @@ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
93
97
  pkg.name = basename(dest)
94
98
  .toLowerCase()
95
99
  .replace(/[^a-z0-9-]/g, '-');
100
+
101
+ /*
102
+ * WHICH incanto this scaffold will install.
103
+ *
104
+ * The template pins `^<version>`, so `bun install` resolves the REGISTRY build
105
+ * — even when the scaffolder itself came from a local tarball. Someone
106
+ * verifying a release candidate that shares its version with a published one
107
+ * then measures the published artifact for an hour and never sees a difference:
108
+ * the version matches, the install succeeds, and a `main.ts` that typechecks
109
+ * against the candidate does not typecheck against the registry copy.
110
+ *
111
+ * `--local` pins the package this bin is running from, and either way the pin
112
+ * is printed. A silent resolution is the part that cost the hour.
113
+ */
114
+ const ENGINE_ROOT = PKG;
115
+ if (argv.includes('--local')) {
116
+ pkg.dependencies = { ...pkg.dependencies, incanto: `file:${ENGINE_ROOT}` };
117
+ }
118
+ const pin = pkg.dependencies?.incanto ?? '(none)';
96
119
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
97
120
 
98
121
  console.log(`
99
122
  ${template} → ${target}
123
+ incanto ${pin}
100
124
 
101
125
  cd ${target}
102
126
  bun install # or: npm install
@@ -104,6 +128,10 @@ console.log(`
104
128
  bun run verify # prove it plays, headlessly
105
129
  bun run check # validate the scene JSON
106
130
 
131
+ When it plays, ship it:
132
+ bun run build # the artifact a player gets
133
+ bunx incanto-serve # serve dist/ so verify/frame/logs can see the BUILT game
134
+
107
135
  Vibe-coding? Install the agent skills too:
108
136
  bunx incanto-skills claude # or: opencode
109
137
  `);
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ /**
4
+ * Serve a BUILT game, and let the inspect tools see it.
5
+ *
6
+ * `incanto-frame` and `incanto-logs` ask a dev server, which asks the page over
7
+ * vite's HMR channel. A production build has no HMR, so nothing could look at
8
+ * the artifact an author actually deploys:
9
+ *
10
+ * $ node …/incanto-logs.mjs --port 8791 # dist/ on a static server
11
+ * no incanto dev server found.
12
+ * $ incanto-verify src/game.scene.json
13
+ * ? draws — no incanto dev server found.
14
+ * ? says — no incanto dev server found.
15
+ *
16
+ * The boot path is the only code a BUILD adds over a SCENE — `main.ts`, the
17
+ * loading overlay, the asset base path, everything after `createGame*` resolves
18
+ * — and it was the one thing no rung could reach.
19
+ *
20
+ * This serves `dist/` as plain static files (no bundler, no transform) and adds
21
+ * exactly two things: the `/__incanto/*` endpoints the CLIs already speak, and
22
+ * one inline script injected into `index.html` that relays them to the page.
23
+ * Nothing is added to the files on disk, so what you measure is what you ship.
24
+ *
25
+ * bunx incanto-serve # ./dist on :4180
26
+ * bunx incanto-serve build --port 5050
27
+ * bunx incanto-verify # now measures the BUILT game
28
+ */
29
+ import { createServer } from 'node:http';
30
+ import { createRequire } from 'node:module';
31
+ import { extname, join, normalize, resolve, sep } from 'node:path';
32
+ import { fileURLToPath, pathToFileURL } from 'node:url';
33
+
34
+ const PKG = resolve(fileURLToPath(import.meta.url), '../..');
35
+ const DIST = join(PKG, 'dist');
36
+ if (!existsSync(join(DIST, 'vite.js'))) {
37
+ console.error('incanto: the package is not built (dist/vite.js missing).');
38
+ process.exit(1);
39
+ }
40
+ const { createStaticInspectHost, serveFrameEndpoints } = await import(
41
+ pathToFileURL(join(DIST, 'vite.js')).href
42
+ );
43
+ const VERSION = createRequire(import.meta.url)('../package.json').version;
44
+
45
+ const args = process.argv.slice(2);
46
+ if (args.includes('--help') || args.includes('-h')) {
47
+ console.error(`Usage: incanto-serve [dir] [--port N] [--host H]
48
+
49
+ Serve a BUILT game (default ./dist) so \`incanto-frame\`, \`incanto-logs\` and
50
+ \`incanto-verify\` can measure the artifact you actually deploy. Files are served
51
+ byte-for-byte; the inspect client is injected into index.html at serve time and
52
+ never written to disk.`);
53
+ process.exit(0);
54
+ }
55
+ const flag = (name, fallback) => {
56
+ const at = args.indexOf(name);
57
+ return at >= 0 && args[at + 1] ? args[at + 1] : fallback;
58
+ };
59
+ const port = Number(flag('--port', '4180'));
60
+ const host = flag('--host', '127.0.0.1');
61
+ const dir = resolve(
62
+ args.find(
63
+ (a) =>
64
+ !a.startsWith('--') &&
65
+ args[args.indexOf(a) - 1] !== '--port' &&
66
+ args[args.indexOf(a) - 1] !== '--host',
67
+ ) ?? 'dist',
68
+ );
69
+
70
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
71
+ console.error(
72
+ `incanto-serve: no such directory: ${dir}\n` +
73
+ ' Build first (`bun run build`), or name the output directory.',
74
+ );
75
+ process.exit(1);
76
+ }
77
+ if (!existsSync(join(dir, 'index.html'))) {
78
+ console.error(
79
+ `incanto-serve: ${dir} has no index.html — that is not a built game.\n` +
80
+ ' Point this at the directory your bundler wrote.',
81
+ );
82
+ process.exit(1);
83
+ }
84
+
85
+ const inspect = createStaticInspectHost(process.cwd());
86
+ serveFrameEndpoints(inspect, VERSION);
87
+
88
+ const TYPES = {
89
+ '.html': 'text/html; charset=utf-8',
90
+ '.js': 'text/javascript; charset=utf-8',
91
+ '.mjs': 'text/javascript; charset=utf-8',
92
+ '.css': 'text/css; charset=utf-8',
93
+ '.json': 'application/json; charset=utf-8',
94
+ '.png': 'image/png',
95
+ '.jpg': 'image/jpeg',
96
+ '.jpeg': 'image/jpeg',
97
+ '.webp': 'image/webp',
98
+ '.gif': 'image/gif',
99
+ '.svg': 'image/svg+xml',
100
+ '.ico': 'image/x-icon',
101
+ '.glb': 'model/gltf-binary',
102
+ '.gltf': 'model/gltf+json',
103
+ '.mp3': 'audio/mpeg',
104
+ '.ogg': 'audio/ogg',
105
+ '.wav': 'audio/wav',
106
+ '.wasm': 'application/wasm',
107
+ '.woff2': 'font/woff2',
108
+ '.ttf': 'font/ttf',
109
+ };
110
+
111
+ /** Inject the relay ahead of everything, so it exists before the game boots. */
112
+ function withInspectClient(html) {
113
+ const tag = `<script>${inspect.clientScript()}</script>`;
114
+ if (html.includes('</head>')) return html.replace('</head>', `${tag}</head>`);
115
+ return tag + html;
116
+ }
117
+
118
+ const server = createServer((req, res) => {
119
+ const url = (req.url ?? '/').split('?')[0] ?? '/';
120
+ if (url.startsWith('/__incanto/')) {
121
+ // `originalUrl` is what the endpoints read for the query string; connect
122
+ // strips the mount path off `url` and these were written against that.
123
+ req.originalUrl = req.url;
124
+ if (inspect.handleInspect(req, res)) return;
125
+ res.statusCode = 404;
126
+ res.end('{}');
127
+ return;
128
+ }
129
+ // A path outside the served directory is a traversal attempt, not a typo.
130
+ const rel = normalize(decodeURIComponent(url)).replace(/^(\.\.[/\\])+/, '');
131
+ let file = join(dir, rel);
132
+ if (!file.startsWith(dir + sep) && file !== dir) file = dir;
133
+ if (!existsSync(file) || statSync(file).isDirectory()) file = join(dir, 'index.html');
134
+
135
+ const type = TYPES[extname(file)] ?? 'application/octet-stream';
136
+ if (file.endsWith('index.html')) {
137
+ const html = withInspectClient(readFileSync(file, 'utf8'));
138
+ res.statusCode = 200;
139
+ res.setHeader('content-type', TYPES['.html']);
140
+ res.end(html);
141
+ return;
142
+ }
143
+ res.statusCode = 200;
144
+ res.setHeader('content-type', type);
145
+ res.end(readFileSync(file));
146
+ });
147
+
148
+ server.on('error', (e) => {
149
+ if (e?.code === 'EADDRINUSE') {
150
+ console.error(`incanto-serve: port ${port} is already in use — pass --port N.`);
151
+ process.exit(1);
152
+ }
153
+ throw e;
154
+ });
155
+
156
+ server.listen(port, host, () => {
157
+ console.log(`incanto-serve ${dir}`);
158
+ console.log(` http://${host}:${port}/`);
159
+ console.log(' open it, then run `incanto-verify` / `incanto-frame` / `incanto-logs`.');
160
+ });
@@ -449,6 +449,30 @@ if (rungs[0].status === 'pass') {
449
449
  ? 'bring the preview window to the front — a hidden tab does not render, and a frame is captured inside a render'
450
450
  : 'start your dev server, open the page, and run this again — the pixels are in the browser',
451
451
  });
452
+ } else if (report.hidden) {
453
+ /*
454
+ * A hidden page is UNMEASURED, not passed.
455
+ *
456
+ * A browser stops animating a page it considers hidden — a window merely
457
+ * covered by another counts — and the back buffer keeps whatever it last
458
+ * drew. Measured at one instant, same page:
459
+ *
460
+ * incanto-frame --do the page is hidden … a frozen game (exit 1)
461
+ * incanto-logs frame rate not measurable — hidden
462
+ * incanto-frame subject fills 17.5% of frame
463
+ * incanto-verify ✓ draws — subject fills 17.5%
464
+ *
465
+ * Two of six rungs reported ✓ off a stale buffer while the engine already
466
+ * knew, and said elsewhere, that the page was drawing nothing. The skills
467
+ * state the rule: an unmeasured rung is not a failure, and this is exactly
468
+ * the unmeasured case.
469
+ */
470
+ rungs.push({
471
+ name: 'draws',
472
+ status: 'unmeasured',
473
+ summary: 'the page is HIDDEN — those pixels are whatever it last drew',
474
+ fix: 'bring the window to the front (one covered by another counts as hidden) and run this again',
475
+ });
452
476
  } else if (report.black) {
453
477
  rungs.push({
454
478
  name: 'draws',
@@ -464,10 +488,26 @@ if (rungs[0].status === 'pass') {
464
488
  fix: 'check the camera: `incanto-frame --out shot.png` and look at what it is aimed at',
465
489
  });
466
490
  } else {
491
+ /*
492
+ * A rung that PASSED can still have something to say, and this one was
493
+ * dropping it. `incanto-frame` prints `CLIPPED by the frame edge` and
494
+ * `the subject fills almost nothing` — real observations about a shot that
495
+ * is technically non-black — and the ladder replaced all of it with one
496
+ * coverage number. The `loads` rung has carried its tool's warnings since
497
+ * the day a scene described as "It will render black" came back green.
498
+ */
499
+ const notes = [];
500
+ if (report.subject.clipped) {
501
+ notes.push('the subject is CLIPPED by the frame edge — part of it is off screen');
502
+ }
503
+ if (report.uniform) {
504
+ notes.push('one flat colour — the camera may be inside geometry, or only the sky is drawn');
505
+ }
467
506
  rungs.push({
468
507
  name: 'draws',
469
508
  status: 'pass',
470
509
  summary: `subject fills ${(report.subject.coverage * 100).toFixed(1)}% of the frame`,
510
+ ...(notes.length > 0 ? { notes } : {}),
471
511
  });
472
512
  }
473
513
  }
package/bin/incanto.mjs CHANGED
@@ -40,6 +40,7 @@ const COMMANDS = {
40
40
  feel: ['incanto-feel.mjs', 'measure jump height, run speed, facing'],
41
41
  frame: ['incanto-frame.mjs', 'what the running page is drawing right now'],
42
42
  logs: ['incanto-logs.mjs', 'what the running page has been saying'],
43
+ serve: ['incanto-serve.mjs', 'serve a BUILT game so the tools can measure it'],
43
44
  editor: ['incanto-editor.mjs', 'the visual scene composer'],
44
45
  model: ['incanto-model.mjs', "a GLB's real bounds, animations and rig"],
45
46
  assets: ['incanto-assets.mjs', 'the built-in art and audio catalog'],
package/dist/2d.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { c as showBootFailure, i as ParticleSim, o as ParticleView, r as SpatialPose, s as isWebGLAvailable } from "./audio-player-DOrq7sP-.js";
2
- 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-DoFPYrgo.js";
1
+ import { c as showBootFailure, i as ParticleSim, o as ParticleView, r as SpatialPose, s as isWebGLAvailable } from "./audio-player-C0j2k-yB.js";
2
+ 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-rm8vVzu_.js";
3
3
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-CnIOiyNJ.js";
4
4
  import { i as SceneJson, s as JsonObject } from "./schema-B6ugCV1Q.js";
5
- import { r as FrameStats } from "./frame-report-DCnHFmto.js";
6
- import { t as LoadSceneOptions } from "./loader-CcB533FR.js";
5
+ import { r as FrameStats } from "./frame-report-DNxDAb1w.js";
6
+ import { t as LoadSceneOptions } from "./loader-DadV2QCT.js";
7
7
  import { n as AnimationEntry } from "./sprite-animation-CMr6f1K2.js";
8
8
  import { Group, Mesh, Object3D, Scene, Texture } from "three";
9
9
  import * as RapierNs from "@dimforge/rapier2d-compat";
@@ -972,9 +972,11 @@ declare class TileMap2D extends Node2D {
972
972
  private _solid;
973
973
  /** Rebuild flags — raised when the map-shaping props are REPLACED. */
974
974
  private geometryDirty;
975
- /** Reported once per atlas, so a bad grid says so on the first build only. */
975
+ /** `texture|highestTile` already reported, so a bad grid says so once. */
976
976
  private gridChecked;
977
977
  private collidersDirty;
978
+ /** The `scale` the colliders were last built for — see `rebuildColliders`. */
979
+ private builtScale;
978
980
  /** Replace the whole array to change the map (mutations are not watched). */
979
981
  get cells(): (string | number[])[];
980
982
  set cells(value: (string | number[])[]);
package/dist/2d.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
2
  import { t as showBootFailure } from "./boot-failure-CKYrEcGF.js";
3
- import { t as isWebGLAvailable } from "./webgl-unavailable-N9nQqesw.js";
4
- import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-IZIydDwI.js";
5
- 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-ibCjm-wH.js";
6
- import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-B7Y6dPZO.js";
3
+ import { t as isWebGLAvailable } from "./webgl-unavailable-Z5_g8x9H.js";
4
+ import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-CwxEDlCm.js";
5
+ 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-DbVArsns.js";
6
+ import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-CF5yyjbj.js";
7
7
  //#region src/2d/library-sprite.ts
8
8
  /**
9
9
  * What a `CharacterController2D`/`3D` will ask a skin to play, and the clip in
package/dist/3d.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { c as showBootFailure, i as ParticleSim, o as ParticleView, r as SpatialPose, s as isWebGLAvailable } from "./audio-player-DOrq7sP-.js";
2
- import { D as QualityCaps, 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-DoFPYrgo.js";
1
+ import { c as showBootFailure, i as ParticleSim, o as ParticleView, r as SpatialPose, s as isWebGLAvailable } from "./audio-player-C0j2k-yB.js";
2
+ import { D as QualityCaps, 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-rm8vVzu_.js";
3
3
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-CnIOiyNJ.js";
4
4
  import { i as SceneJson$1, s as JsonObject } from "./schema-B6ugCV1Q.js";
5
- import { a as GridCell, c as diffText, d as frameText, i as FrameStatsOptions, l as frameSignature, n as FrameSignature, o as SIGNATURE_GRID, r as FrameStats, s as diffSignatures, t as FrameDiff, u as frameStats } from "./frame-report-DCnHFmto.js";
6
- import { t as LoadSceneOptions } from "./loader-CcB533FR.js";
5
+ import { a as GridCell, c as diffText, d as frameText, i as FrameStatsOptions, l as frameSignature, n as FrameSignature, o as SIGNATURE_GRID, r as FrameStats, s as diffSignatures, t as FrameDiff, u as frameStats } from "./frame-report-DNxDAb1w.js";
6
+ import { t as LoadSceneOptions } from "./loader-DadV2QCT.js";
7
7
  import { n as PathGrid } from "./pathfinding-_fGrCFmH.js";
8
8
  import { AnimationClip, AnimationMixer, BufferGeometry, Color, DirectionalLight, Group, InstancedMesh, Mesh, MeshPhysicalMaterial, Object3D, PerspectiveCamera, Scene, ShaderMaterial, Texture, Vector3, WebGLRenderer } from "three";
9
9
  import { VRM } from "@pixiv/three-vrm";
package/dist/3d.js CHANGED
@@ -1,12 +1,12 @@
1
- import { c as parseEnvironment3D, l as sunDirectionFromElevationAzimuth, s as horizonColorFromSky, u as sunDirectionFromSky } from "./loader-BC4PNtJX.js";
1
+ import { c as parseEnvironment3D, l as sunDirectionFromElevationAzimuth, s as horizonColorFromSky, u as sunDirectionFromSky } from "./loader-DZE7B07H.js";
2
2
  import { t as showBootFailure } from "./boot-failure-CKYrEcGF.js";
3
- import { t as isWebGLAvailable } from "./webgl-unavailable-N9nQqesw.js";
4
- 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";
5
- import { G as StaticBody3D, H as CharacterBody3D, K as Node3D, L as Water3D, R as WATER_CUTOUT_MAX, U as PhysicsBody3D, V as Area3D, W as RigidBody3D, Y as WATER_MAX_RIPPLES, z as WaterCutout3D } from "./gameplay-DM1eu_cV.js";
6
- 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-CybQXNqS.js";
7
- import { a as Environment3D, c as AssetStore3D, i as syncTree, o as applyQualityTier3D, r as Renderer3D, s as setEnvironment3D, t as createGame3D } from "./create-game-DbWtVTxD.js";
3
+ import { t as isWebGLAvailable } from "./webgl-unavailable-Z5_g8x9H.js";
4
+ import { a as frameSignature, n as diffSignatures, o as frameStats, r as diffText, s as frameText, t as SIGNATURE_GRID } from "./frame-report-Dlq13Gyj.js";
5
+ import { G as StaticBody3D, H as CharacterBody3D, K as Node3D, L as Water3D, R as WATER_CUTOUT_MAX, U as PhysicsBody3D, V as Area3D, W as RigidBody3D, Y as WATER_MAX_RIPPLES, z as WaterCutout3D } from "./gameplay-D1KixWK8.js";
6
+ 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-BO-7XoFV.js";
7
+ import { a as Environment3D, c as AssetStore3D, i as syncTree, o as applyQualityTier3D, r as Renderer3D, s as setEnvironment3D, t as createGame3D } from "./create-game-CvJ1wSRP.js";
8
8
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
9
- import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-bG3n70Ky.js";
9
+ import { n as enablePhysics3D, t as Physics3D } from "./physics-3d-DbYaEcZy.js";
10
10
  //#region src/3d/model-verdict.ts
11
11
  /** Mixamo exports every bone as `mixamorigX`; the retargeter binds by that name. */
12
12
  const MIXAMO = /^mixamorig[:_]?/i;
@@ -1,4 +1,4 @@
1
- import { d as PropSchema, dt as Listener, jt as Node } from "./behavior-DoFPYrgo.js";
1
+ import { d as PropSchema, dt as Listener, jt as Node } from "./behavior-rm8vVzu_.js";
2
2
  import { a as Rng } from "./schema-B6ugCV1Q.js";
3
3
 
4
4
  //#region src/core/boot-failure.d.ts
@@ -234,28 +234,47 @@ declare class AudioPlayer extends Node {
234
234
  /** The `spatial` option for the SFX path, or undefined when not spatial. */
235
235
  private spatialPlay;
236
236
  /**
237
- * Start the sound. `at` schedules it on the AUDIO clock instead of now.
237
+ * Start the sound, now.
238
+ *
239
+ * **Takes no arguments, on purpose.** This is the method scenes wire signals
240
+ * to — `incanto-audio.md` teaches
241
+ * `{"signal": "collected", "from": "Player/Collector", "to": "Coin",
242
+ * "handler": "play"}` — and a signal hands its handler whatever it carries.
243
+ * `collected` leads with a NUMBER. When this briefly took an optional
244
+ * scheduling time, that wiring became `play(10)`, which scheduled the pickup
245
+ * sound at ABSOLUTE audio-clock second 10: inaudible for the first ten
246
+ * seconds of the game and fine thereafter. The same optional parameter also
247
+ * made `Function.length` 1, so the engine's own arity checker cried wolf on
248
+ * the shipped `platformer-2d` template's own harness.
249
+ *
250
+ * Scheduling has its own name: {@link playAt}.
251
+ */
252
+ /**
253
+ * Start the sound at `when` on the AUDIO clock (`engine.sfx.now` + a lead).
238
254
  *
239
255
  * A frame is a 16.67 ms grid at 60 Hz and 33.33 ms at 30, so a sound fired
240
256
  * from `update()` cannot land closer than one frame to where a chart wants
241
- * it — measured 0 of 64 notes scheduled on the documented path. Queue it a
242
- * lead ahead of `engine.sfx.now` and the same notes land at |mean| 0.000000
243
- * ms:
257
+ * it — measured 0 of 64 notes given a scheduled start. Queue them a lead
258
+ * ahead and the same notes land at |mean| 0.000000 ms:
244
259
  *
245
260
  * ```ts
246
- * const lead = 0.08; // schedule this far ahead
247
- * for (const note of dueSoon(engine.sfx.now + lead)) {
248
- * hit.play(engine.sfx.now + (note.atSec - songSeconds));
261
+ * const LEAD = 0.08;
262
+ * for (const note of dueSoon(engine.sfx.now + LEAD)) {
263
+ * hit.playAt(startedAt + note.atSec);
249
264
  * }
250
265
  * ```
251
266
  *
252
267
  * PRESETS only — a `src` clip goes through an `<audio>` element, which has no
253
- * scheduling clock, and passing `at` for one is ignored rather than silently
254
- * approximated. Everything else is unchanged: the bus gain applies, the sound
255
- * lands in `engine.audio.recent()`, and `muted` still silences it, none of
256
- * which a hand-rolled `AudioContext` scheduler keeps.
268
+ * scheduling clock, so `playAt` on one plays immediately rather than being
269
+ * silently approximated. A time already past plays now (Web Audio's own
270
+ * rule), so a late scheduler is late, not silent. The bus gain, the
271
+ * `engine.audio.recent()` entry and `muted` all still apply.
272
+ *
273
+ * Separate from {@link play} because `play` is what scenes wire signals to,
274
+ * and a signal hands its handler whatever it carries.
257
275
  */
258
- play(at?: number): void;
276
+ playAt(when: number): void;
277
+ play(): void;
259
278
  /**
260
279
  * Procedural-SFX path: synthesize the preset and fire it through WebAudio
261
280
  * (low-latency, overlap-friendly). Headless (no AudioContext) plays nothing —