incanto 0.46.0 → 0.47.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.
- package/bin/incanto-check.mjs +5 -1
- package/bin/incanto-feel.mjs +5 -1
- package/bin/incanto-frame.mjs +57 -6
- package/bin/incanto-model.mjs +79 -10
- package/bin/incanto-playtest.mjs +5 -1
- package/bin/incanto-verify.mjs +214 -0
- package/dist/3d.d.ts +89 -4
- package/dist/3d.js +90 -3
- package/dist/{create-game-BtCNdkjI.js → create-game-D16MVIPO.js} +21 -2
- package/dist/{frame-report-Ct8XgsmV.d.ts → frame-report-DZ70IY26.d.ts} +25 -0
- package/dist/{frame-report-Lr3VO24R.js → frame-report-njybhZon.js} +130 -3
- package/dist/index.js +1 -1
- package/dist/react.js +1 -1
- package/dist/{src-B43b6bZo.js → src-Ca3oV1fe.js} +1 -1
- package/dist/{test-BboGNQr-.js → test-E4-otKqK.js} +51 -4
- package/dist/test.d.ts +55 -1
- package/dist/test.js +2 -2
- package/dist/vite.d.ts +13 -2
- package/dist/vite.js +27 -9
- package/editor/assets/{agent8-Dl5ZZLu-.js → agent8-_007gPF8.js} +1 -1
- package/editor/assets/{debug-j9Pi6RvO.js → debug-0DI_MJaq.js} +1 -1
- package/editor/assets/{index-Y-93awAL.js → index-B-6eYZEi.js} +92 -92
- package/editor/index.html +1 -1
- package/package.json +3 -2
- package/skills/incanto-3d-models.md +30 -0
- package/skills/incanto-verifying-your-game.md +60 -1
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
package/bin/incanto-check.mjs
CHANGED
|
@@ -106,4 +106,8 @@ if (args.json) {
|
|
|
106
106
|
}
|
|
107
107
|
console.log(`\n${results.length - failed.length}/${results.length} scene(s) valid`);
|
|
108
108
|
}
|
|
109
|
-
process.exit(
|
|
109
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
110
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
111
|
+
// report read by another program came back truncated — silently, and only
|
|
112
|
+
// when piped, which is the only way a program reads it.
|
|
113
|
+
process.exitCode = failed.length === 0 ? 0 : 1;
|
package/bin/incanto-feel.mjs
CHANGED
|
@@ -109,4 +109,8 @@ if (!args.json) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
process.exit(
|
|
112
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
113
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
114
|
+
// report read by another program came back truncated — silently, and only
|
|
115
|
+
// when piped, which is the only way a program reads it.
|
|
116
|
+
process.exitCode = report.player ? 0 : 1;
|
package/bin/incanto-frame.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
/**
|
|
4
4
|
* incanto-frame — what is actually on screen, as numbers.
|
|
5
5
|
*
|
|
@@ -37,6 +37,8 @@ const flag = (name) => {
|
|
|
37
37
|
return next && !next.startsWith('-') ? next : '';
|
|
38
38
|
};
|
|
39
39
|
const gridArg = flag('--grid');
|
|
40
|
+
const outArg = flag('--out');
|
|
41
|
+
const sizeArg = flag('--size');
|
|
40
42
|
const remember = flag('--remember');
|
|
41
43
|
const diff = flag('--diff');
|
|
42
44
|
const threshold = flag('--threshold');
|
|
@@ -45,6 +47,8 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
45
47
|
|
|
46
48
|
--json the full report as JSON instead of prose
|
|
47
49
|
--grid WxH cells across and down (default 16x9)
|
|
50
|
+
--out FILE also write the frame as a PNG you can look at
|
|
51
|
+
--size N longest side of that PNG (default 512)
|
|
48
52
|
--port N skip discovery and use this port
|
|
49
53
|
|
|
50
54
|
--remember [L] keep this frame as the baseline named L (default "last")
|
|
@@ -52,7 +56,14 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
52
56
|
--threshold N a cell has changed when it moves this far, 0..255 (default 3)
|
|
53
57
|
|
|
54
58
|
Run your game's dev server and open the preview page first: the pixels live in
|
|
55
|
-
the browser, not in the dev server.
|
|
59
|
+
the browser, not in the dev server. Nothing headless is involved and no browser
|
|
60
|
+
is installed: the page that is already open does the rendering.
|
|
61
|
+
|
|
62
|
+
The report says whether anything was drawn, where the subject is and how much of
|
|
63
|
+
the frame it fills. When that is not enough, look:
|
|
64
|
+
|
|
65
|
+
incanto-frame --out shot.png # then open it, or read it
|
|
66
|
+
|
|
56
67
|
|
|
57
68
|
The report of ONE frame has a floor: a one-pixel seam is a fraction of a level
|
|
58
69
|
once a cell is averaged, and nothing can tell it apart from a thin rope in the
|
|
@@ -138,6 +149,7 @@ if (!found) {
|
|
|
138
149
|
|
|
139
150
|
const params = new URLSearchParams();
|
|
140
151
|
if (gridArg !== null) params.set('grid', gridArg);
|
|
152
|
+
if (outArg) params.set('image', sizeArg || '512');
|
|
141
153
|
if (remember !== null) params.set('remember', remember);
|
|
142
154
|
if (diff !== null) params.set('diff', diff);
|
|
143
155
|
if (threshold) params.set('threshold', threshold);
|
|
@@ -147,10 +159,22 @@ const res = await fetch(`http://${found.host}:${found.port}/__incanto/frame${q ?
|
|
|
147
159
|
});
|
|
148
160
|
const body = await res.json().catch(() => null);
|
|
149
161
|
|
|
162
|
+
if (body?.error === 'page-not-drawing') {
|
|
163
|
+
console.error(
|
|
164
|
+
`the page on :${found.port} is connected but not drawing.\n` +
|
|
165
|
+
' A frame is captured inside a render, and a browser stops rendering a tab it\n' +
|
|
166
|
+
' considers hidden — including a window covered by another one. Bring the\n' +
|
|
167
|
+
' preview to the front and run this again.',
|
|
168
|
+
);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
150
171
|
if (res.status === 504 || body?.error === 'no-page-connected') {
|
|
151
172
|
console.error(
|
|
152
|
-
`the dev server is running on :${found.port}, but no page
|
|
153
|
-
' Open the preview in a browser — the pixels are there, not in the server
|
|
173
|
+
`the dev server is running on :${found.port}, but no page answered.\n` +
|
|
174
|
+
' Open the preview in a browser — the pixels are there, not in the server.\n' +
|
|
175
|
+
' If it IS open: a window covered by another one counts as hidden, and a\n' +
|
|
176
|
+
' hidden tab gets no animation frames — such a page never finishes loading\n' +
|
|
177
|
+
' and cannot draw. Bring it to the front.',
|
|
154
178
|
);
|
|
155
179
|
process.exit(1);
|
|
156
180
|
}
|
|
@@ -165,10 +189,33 @@ if (report.error) {
|
|
|
165
189
|
process.exit(1);
|
|
166
190
|
}
|
|
167
191
|
|
|
192
|
+
/** The picture, if one was asked for. Written before anything is printed, so a
|
|
193
|
+
* report that mentions the file is a report whose file exists. */
|
|
194
|
+
let wroteImage = null;
|
|
195
|
+
if (outArg) {
|
|
196
|
+
const dataUrl = typeof report.image === 'string' ? report.image : null;
|
|
197
|
+
const base64 = dataUrl?.slice(dataUrl.indexOf(',') + 1);
|
|
198
|
+
if (!base64) {
|
|
199
|
+
console.error(
|
|
200
|
+
'the page returned no image — it is running an engine older than this CLI,\n' +
|
|
201
|
+
' or its renderer cannot capture frames.',
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
writeFileSync(outArg, Buffer.from(base64, 'base64'));
|
|
205
|
+
wroteImage = outArg;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
168
209
|
if (asJson) {
|
|
169
|
-
|
|
210
|
+
// The base64 image is for the file, not for a terminal — the path is the
|
|
211
|
+
// useful part and a megabyte of it in a pipe is not.
|
|
212
|
+
const { image: _image, ...rest } = report;
|
|
213
|
+
process.stdout.write(
|
|
214
|
+
`${JSON.stringify({ ...body, report: rest, ...(wroteImage ? { image: wroteImage } : {}) }, null, 2)}\n`,
|
|
215
|
+
);
|
|
170
216
|
} else {
|
|
171
217
|
const lines = [frameText(report)];
|
|
218
|
+
if (wroteImage) lines.push(`wrote ${wroteImage} — open it, or read it`);
|
|
172
219
|
if (body.diffError === 'no-baseline') {
|
|
173
220
|
lines.push(
|
|
174
221
|
`no baseline named "${diff || 'last'}" — run \`incanto-frame --remember${diff ? ` ${diff}` : ''}\` first,\n` +
|
|
@@ -183,6 +230,10 @@ if (asJson) {
|
|
|
183
230
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
184
231
|
}
|
|
185
232
|
// A missing baseline is a usage error, not a picture: fail so a script notices.
|
|
186
|
-
process.exit(
|
|
233
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
234
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
235
|
+
// report read by another program came back truncated — silently, and only
|
|
236
|
+
// when piped, which is the only way a program reads it.
|
|
237
|
+
process.exitCode = report.black || body.diffError ? 1 : 0;
|
|
187
238
|
|
|
188
239
|
void parseProcNetTcp; // re-exported for tests; referenced so bundlers keep it
|
package/bin/incanto-model.mjs
CHANGED
|
@@ -9,14 +9,25 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Bounding boxes come from glTF POSITION accessor min/max transformed through
|
|
11
11
|
* each node's TRS chain — for skinned meshes that is the BIND pose.
|
|
12
|
+
*
|
|
13
|
+
* A URL works anywhere a path does. Finding an asset is the agent8 library's
|
|
14
|
+
* job (its vector search matches meaning, which no filename filter can); this
|
|
15
|
+
* answers the question that search cannot — what will this file DO in a scene.
|
|
12
16
|
*/
|
|
13
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const { modelVerdict, verdictText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
|
|
14
23
|
|
|
15
24
|
function parseArgs(argv) {
|
|
16
|
-
const args = { file: undefined, json: false };
|
|
17
|
-
for (
|
|
25
|
+
const args = { file: undefined, json: false, key: undefined };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
18
28
|
if (a === '--json') args.json = true;
|
|
19
29
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
30
|
+
else if (a === '--key') args.key = argv[++i];
|
|
20
31
|
else if (!a.startsWith('-') && !args.file) args.file = a;
|
|
21
32
|
else {
|
|
22
33
|
console.error(`unknown argument: ${a}`);
|
|
@@ -26,19 +37,49 @@ function parseArgs(argv) {
|
|
|
26
37
|
return args;
|
|
27
38
|
}
|
|
28
39
|
|
|
40
|
+
const isUrl = (s) => /^https?:\/\//i.test(s ?? '');
|
|
41
|
+
|
|
42
|
+
/** A key from a url or path: `.../goblin_king.glb` → `goblin_king`. */
|
|
43
|
+
function keyFrom(source) {
|
|
44
|
+
const file = (source.split(/[?#]/)[0] ?? '').split('/').pop() ?? '';
|
|
45
|
+
return (
|
|
46
|
+
file
|
|
47
|
+
.replace(/\.[^.]+$/, '')
|
|
48
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '_')
|
|
49
|
+
.replace(/_{2,}/g, '_')
|
|
50
|
+
.replace(/^_+|_+$/g, '')
|
|
51
|
+
.toLowerCase() || 'model'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
29
55
|
const args = parseArgs(process.argv.slice(2));
|
|
30
56
|
if (args.help || args.error || !args.file) {
|
|
31
|
-
console.log(`Usage: npx incanto-model <file.glb|.gltf|.vrm> [--json]
|
|
57
|
+
console.log(`Usage: npx incanto-model <file.glb|.gltf|.vrm|URL> [--json] [--key NAME]
|
|
32
58
|
|
|
33
59
|
Reports hierarchy, mesh stats, scene bounding box, animations, and VRM meta —
|
|
34
|
-
the numbers you need to size and place a model in a scene
|
|
60
|
+
the numbers you need to size and place a model in a scene — then says what the
|
|
61
|
+
file will DO in an incanto scene:
|
|
62
|
+
|
|
63
|
+
will the 3d/animations clips play on it (bone names decide, and a mismatch
|
|
64
|
+
keeps the bind pose with only a console warning)
|
|
65
|
+
is it a character's size, or authored in centimetres
|
|
66
|
+
does it stand on its own origin, or will a node at [0,0,0] put it in the sky
|
|
67
|
+
|
|
68
|
+
and prints the scene JSON to paste. A URL works anywhere a path does, so an
|
|
69
|
+
asset the agent8 library found can be checked before it is used.`);
|
|
35
70
|
process.exit(args.help && !args.error ? 0 : 1);
|
|
36
71
|
}
|
|
37
72
|
|
|
38
73
|
// ---- container parsing --------------------------------------------------------------
|
|
39
74
|
|
|
40
|
-
function
|
|
41
|
-
|
|
75
|
+
async function bytesOf(source) {
|
|
76
|
+
if (!isUrl(source)) return readFileSync(source);
|
|
77
|
+
const res = await fetch(source);
|
|
78
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${source}`);
|
|
79
|
+
return Buffer.from(await res.arrayBuffer());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readGltfJson(data) {
|
|
42
83
|
if (data.length >= 12 && data.readUInt32LE(0) === 0x46546c67) {
|
|
43
84
|
// GLB container: header(12) then chunks [len][type][payload]
|
|
44
85
|
let offset = 12;
|
|
@@ -186,11 +227,19 @@ function analyze(json) {
|
|
|
186
227
|
if (node.skin !== undefined) entry.skinned = true;
|
|
187
228
|
nodes.push(entry);
|
|
188
229
|
const mesh = node.mesh !== undefined ? meshes[node.mesh] : null;
|
|
230
|
+
// A SKINNED mesh is placed by its joint matrices, not by the node it hangs
|
|
231
|
+
// off — the node's own TRS is ignored at draw time and is routinely a
|
|
232
|
+
// hundredth-scale leftover from the export. Measured on the stock
|
|
233
|
+
// `base-model.glb`: transforming through the chain reported a 1.5-unit
|
|
234
|
+
// character as 0.003 units tall, because its root carries scale 0.01. Take
|
|
235
|
+
// the vertices as authored for those, and the transformed corners for
|
|
236
|
+
// everything else, where the chain IS the placement.
|
|
237
|
+
const identity = node.skin !== undefined;
|
|
189
238
|
if (mesh?.localMin && mesh.localMax) {
|
|
190
239
|
for (const x of [mesh.localMin[0], mesh.localMax[0]]) {
|
|
191
240
|
for (const y of [mesh.localMin[1], mesh.localMax[1]]) {
|
|
192
241
|
for (const z of [mesh.localMin[2], mesh.localMax[2]]) {
|
|
193
|
-
const p = transformPoint(mat, [x, y, z]);
|
|
242
|
+
const p = identity ? [x, y, z] : transformPoint(mat, [x, y, z]);
|
|
194
243
|
sceneMin = sceneMin ? sceneMin.map((v, i) => Math.min(v, p[i])) : [...p];
|
|
195
244
|
sceneMax = sceneMax ? sceneMax.map((v, i) => Math.max(v, p[i])) : [...p];
|
|
196
245
|
}
|
|
@@ -246,21 +295,30 @@ function analyze(json) {
|
|
|
246
295
|
// ---- output --------------------------------------------------------------------------
|
|
247
296
|
|
|
248
297
|
let report;
|
|
298
|
+
let verdict;
|
|
249
299
|
try {
|
|
250
|
-
const
|
|
300
|
+
const data = await bytesOf(args.file);
|
|
301
|
+
const json = readGltfJson(data);
|
|
251
302
|
if (!json.asset?.version) throw new Error('missing glTF asset header');
|
|
252
303
|
report = {
|
|
253
304
|
file: args.file,
|
|
254
|
-
format:
|
|
305
|
+
format: data.readUInt32LE(0) === 0x46546c67 ? 'glb' : 'gltf',
|
|
255
306
|
...analyze(json),
|
|
256
307
|
};
|
|
308
|
+
verdict = modelVerdict(
|
|
309
|
+
{ ...report, meshes: report.meshes.length },
|
|
310
|
+
{
|
|
311
|
+
url: isUrl(args.file) ? args.file : args.file,
|
|
312
|
+
key: args.key ?? keyFrom(args.file),
|
|
313
|
+
},
|
|
314
|
+
);
|
|
257
315
|
} catch (error) {
|
|
258
316
|
console.error(`not a glTF/GLB/VRM file: ${String(error.message ?? error)}`);
|
|
259
317
|
process.exit(1);
|
|
260
318
|
}
|
|
261
319
|
|
|
262
320
|
if (args.json) {
|
|
263
|
-
console.log(JSON.stringify(report, null, 2));
|
|
321
|
+
console.log(JSON.stringify({ ...report, verdict }, null, 2));
|
|
264
322
|
} else {
|
|
265
323
|
const out = [];
|
|
266
324
|
out.push(`${report.file} (${report.format}${report.vrm ? ` · VRM ${report.vrm.spec}` : ''})`);
|
|
@@ -292,5 +350,16 @@ if (args.json) {
|
|
|
292
350
|
for (const anim of report.animations) {
|
|
293
351
|
out.push(` anim ${anim.name}: ${anim.duration}s`);
|
|
294
352
|
}
|
|
353
|
+
// The verdict is the part you act on, so it goes last — closest to the
|
|
354
|
+
// prompt, where a reader stops.
|
|
355
|
+
out.push('', verdictText(verdict));
|
|
356
|
+
if (verdict.sceneJson) {
|
|
357
|
+
const { assets, node } = verdict.sceneJson;
|
|
358
|
+
out.push(
|
|
359
|
+
'',
|
|
360
|
+
'paste into your scene:',
|
|
361
|
+
JSON.stringify(node ? { assets, node } : { assets }, null, 2),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
295
364
|
console.log(out.join('\n'));
|
|
296
365
|
}
|
package/bin/incanto-playtest.mjs
CHANGED
|
@@ -133,4 +133,8 @@ if (!args.noReplays && !args.json) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
process.exit(
|
|
136
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
137
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
138
|
+
// report read by another program came back truncated — silently, and only
|
|
139
|
+
// when piped, which is the only way a program reads it.
|
|
140
|
+
process.exitCode = report.runs.some((r) => r.outcome === 'won') ? 0 : 1;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* incanto-verify — the whole ladder, one command, one next action.
|
|
4
|
+
*
|
|
5
|
+
* bunx incanto-verify # finds your scene
|
|
6
|
+
* bunx incanto-verify src/game.scene.json --behaviors src/behaviors.ts
|
|
7
|
+
*
|
|
8
|
+
* The rungs already existed and were used one at a time, because using them
|
|
9
|
+
* together means knowing three things nobody writes down: the order, that the
|
|
10
|
+
* first red rung makes the ones above it meaningless, and that "no dev server"
|
|
11
|
+
* is a question unasked rather than a failing game.
|
|
12
|
+
*
|
|
13
|
+
* loads incanto-check the scene is legal and every asset resolves
|
|
14
|
+
* plays incanto-playtest a seeded run can actually finish it
|
|
15
|
+
* draws incanto-frame the GPU drew something, and the subject is in shot
|
|
16
|
+
*
|
|
17
|
+
* Exit 1 when a rung FAILED. An unmeasured rung is not a failure — it prints
|
|
18
|
+
* what to arrange, and says so in the summary.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const { ladderText, ladderVerdict } = await import(
|
|
27
|
+
pathToFileURL(join(PKG, 'dist', 'test.js')).href
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const argv = process.argv.slice(2);
|
|
31
|
+
const asJson = argv.includes('--json');
|
|
32
|
+
const flag = (name) => {
|
|
33
|
+
const i = argv.indexOf(name);
|
|
34
|
+
return i >= 0 ? argv[i + 1] : null;
|
|
35
|
+
};
|
|
36
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
37
|
+
console.log(`Usage: incanto-verify [scene.json] [--behaviors FILE] [--json]
|
|
38
|
+
|
|
39
|
+
Walks the whole ladder and tells you the ONE thing to do next:
|
|
40
|
+
|
|
41
|
+
loads the scene is legal and every asset resolves (incanto-check)
|
|
42
|
+
plays a seeded run can actually finish it (incanto-playtest)
|
|
43
|
+
draws the GPU drew something and the subject is in shot (incanto-frame)
|
|
44
|
+
|
|
45
|
+
Without a scene it looks for one *.scene.json under the current directory.
|
|
46
|
+
"draws" needs a dev server with the page open; without one it is reported as
|
|
47
|
+
NOT MEASURED rather than failed — a missing measurement is not a broken game.`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The scene to verify: the one named, or the only one that can be found. */
|
|
52
|
+
function findScene() {
|
|
53
|
+
const named = argv.find((a) => !a.startsWith('-') && a.endsWith('.json'));
|
|
54
|
+
if (named) return named;
|
|
55
|
+
const found = [];
|
|
56
|
+
const walk = (dir, depth) => {
|
|
57
|
+
if (depth > 4 || found.length > 8) return;
|
|
58
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
59
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
60
|
+
const full = join(dir, entry.name);
|
|
61
|
+
if (entry.isDirectory()) walk(full, depth + 1);
|
|
62
|
+
else if (entry.name.endsWith('.scene.json')) found.push(full);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
walk(process.cwd(), 0);
|
|
67
|
+
} catch {
|
|
68
|
+
/* unreadable tree — the caller can always name the file */
|
|
69
|
+
}
|
|
70
|
+
return { scene: found.length === 1 ? found[0] : null, candidates: found };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bin = (name) => join(PKG, 'bin', `incanto-${name}.mjs`);
|
|
74
|
+
const run = (name, args) =>
|
|
75
|
+
spawnSync(process.execPath, [bin(name), ...args], {
|
|
76
|
+
encoding: 'utf-8',
|
|
77
|
+
timeout: 180_000,
|
|
78
|
+
// A playtest report carries a replay per losing run; the 1MB default
|
|
79
|
+
// truncates it and a truncated report is indistinguishable from a tool
|
|
80
|
+
// that would not start.
|
|
81
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const { scene, candidates } = findScene();
|
|
85
|
+
if (!scene) {
|
|
86
|
+
const report = ladderVerdict([], { candidates });
|
|
87
|
+
console.log(asJson ? JSON.stringify(report, null, 2) : ladderText(report));
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
if (!existsSync(scene) || !statSync(scene).isFile()) {
|
|
91
|
+
console.error(`no such scene: ${scene}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const behaviors = flag('--behaviors');
|
|
96
|
+
const rungs = [];
|
|
97
|
+
|
|
98
|
+
// ---- loads ---------------------------------------------------------------
|
|
99
|
+
{
|
|
100
|
+
const r = run('check', [scene, '--json']);
|
|
101
|
+
const out = safeJson(r.stdout);
|
|
102
|
+
const problems = out?.files?.flatMap((f) => f.problems ?? []) ?? [];
|
|
103
|
+
rungs.push(
|
|
104
|
+
r.status === 0
|
|
105
|
+
? { name: 'loads', status: 'pass', summary: 'the scene is legal and its assets resolve' }
|
|
106
|
+
: {
|
|
107
|
+
name: 'loads',
|
|
108
|
+
status: 'fail',
|
|
109
|
+
summary: problems[0]?.why ?? firstLine(r.stdout || r.stderr) ?? 'incanto-check failed',
|
|
110
|
+
fix: `fix that, then run again — nothing above this rung means anything until it is green (\`incanto-check ${scene}\` for the detail)`,
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- plays ---------------------------------------------------------------
|
|
116
|
+
if (rungs[0].status === 'pass') {
|
|
117
|
+
const args = [scene, '--json', '--runs', '8'];
|
|
118
|
+
if (behaviors) args.push('--behaviors', behaviors);
|
|
119
|
+
const r = run('playtest', args);
|
|
120
|
+
const out = safeJson(r.stdout);
|
|
121
|
+
const won = out?.runs?.filter((x) => x.outcome === 'won').length ?? 0;
|
|
122
|
+
const total = out?.runs?.length ?? 0;
|
|
123
|
+
// A scene that declares no win is not a scene that cannot be won: a
|
|
124
|
+
// walkabout template has no end, and calling that a failure sends its author
|
|
125
|
+
// hunting a bug that was never there.
|
|
126
|
+
const noGoal = out && out.declaresWin === false;
|
|
127
|
+
rungs.push(
|
|
128
|
+
r.status === 0
|
|
129
|
+
? { name: 'plays', status: 'pass', summary: `${won} of ${total} seeded runs finished it` }
|
|
130
|
+
: noGoal
|
|
131
|
+
? {
|
|
132
|
+
name: 'plays',
|
|
133
|
+
status: 'unmeasured',
|
|
134
|
+
summary: `nothing declares a win — ${total} runs played without error, and there was no end to reach`,
|
|
135
|
+
fix: 'if it is meant to be finishable, emit `won` (GameFlow, ScoreKeeper, or your own behaviour)',
|
|
136
|
+
}
|
|
137
|
+
: {
|
|
138
|
+
name: 'plays',
|
|
139
|
+
status: 'fail',
|
|
140
|
+
summary:
|
|
141
|
+
total > 0 ? `no run finished it (${total} tried)` : 'the playtest could not run',
|
|
142
|
+
fix: behaviors
|
|
143
|
+
? `see which runs stalled and where: \`incanto-playtest ${scene} --behaviors ${behaviors}\``
|
|
144
|
+
: `run it with your behaviours — without them the structure plays and your game logic does not: \`incanto-playtest ${scene} --behaviors src/behaviors.ts\``,
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
rungs.push({ name: 'plays', status: 'skipped', summary: 'not run — the scene does not load' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- draws ---------------------------------------------------------------
|
|
152
|
+
{
|
|
153
|
+
const r = run('frame', ['--json']);
|
|
154
|
+
const out = safeJson(r.stdout);
|
|
155
|
+
const report = out?.report;
|
|
156
|
+
if (!report) {
|
|
157
|
+
rungs.push({
|
|
158
|
+
name: 'draws',
|
|
159
|
+
status: 'unmeasured',
|
|
160
|
+
summary: firstLine(r.stderr) ?? 'no dev server with a page open',
|
|
161
|
+
fix: /not drawing/.test(r.stderr ?? '')
|
|
162
|
+
? 'bring the preview window to the front — a hidden tab does not render, and a frame is captured inside a render'
|
|
163
|
+
: 'start your dev server, open the page, and run this again — the pixels are in the browser',
|
|
164
|
+
});
|
|
165
|
+
} else if (report.black) {
|
|
166
|
+
rungs.push({
|
|
167
|
+
name: 'draws',
|
|
168
|
+
status: 'fail',
|
|
169
|
+
summary: 'BLACK SCREEN — nothing was drawn',
|
|
170
|
+
fix: 'no light, no current camera, or nothing in view: `incanto-frame --out shot.png` and look',
|
|
171
|
+
});
|
|
172
|
+
} else if (!report.subject) {
|
|
173
|
+
rungs.push({
|
|
174
|
+
name: 'draws',
|
|
175
|
+
status: 'fail',
|
|
176
|
+
summary: 'nothing but background — the camera is pointed away from the scene',
|
|
177
|
+
fix: 'check the camera: `incanto-frame --out shot.png` and look at what it is aimed at',
|
|
178
|
+
});
|
|
179
|
+
} else {
|
|
180
|
+
rungs.push({
|
|
181
|
+
name: 'draws',
|
|
182
|
+
status: 'pass',
|
|
183
|
+
summary: `subject fills ${(report.subject.coverage * 100).toFixed(1)}% of the frame`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The JSON in `text`, past whatever a dependency printed first. Rapier's
|
|
190
|
+
* deprecation notice lands on stdout ahead of the report, and treating that as
|
|
191
|
+
* "the tool could not run" is how this reported a healthy scene as broken.
|
|
192
|
+
*/
|
|
193
|
+
function safeJson(text) {
|
|
194
|
+
const raw = text ?? '';
|
|
195
|
+
const start = raw.indexOf('{');
|
|
196
|
+
if (start < 0) return null;
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(raw.slice(start));
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function firstLine(text) {
|
|
204
|
+
const line = (text ?? '').split('\n').find((l) => l.trim());
|
|
205
|
+
return line ? line.trim() : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const verdict = ladderVerdict(rungs);
|
|
209
|
+
console.log(asJson ? JSON.stringify({ scene, ...verdict }, null, 2) : ladderText(verdict));
|
|
210
|
+
// `exitCode`, never `process.exit()`: stdout to a PIPE is written
|
|
211
|
+
// asynchronously, and exiting discards whatever has not flushed. A --json
|
|
212
|
+
// report read by another program came back truncated — silently, and only
|
|
213
|
+
// when piped, which is the only way a program reads it.
|
|
214
|
+
process.exitCode = verdict.ok ? 0 : 1;
|
package/dist/3d.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { D as QualityCaps, Et as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engine, d as PropSchema, n as BehaviorCtor, w as GameStats } from "./behavior-62q0HWBO.js";
|
|
2
2
|
import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
|
|
3
|
-
import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
|
|
3
|
+
import { i as SceneJson$1, s as JsonObject } from "./schema-CFeioQRE.js";
|
|
4
4
|
import { t as LoadSceneOptions } from "./loader-CeyU_bm1.js";
|
|
5
5
|
import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
|
|
6
|
-
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-
|
|
6
|
+
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-DZ70IY26.js";
|
|
7
7
|
import { n as PathGrid, s as SpatialPose } from "./pathfinding-C49JSNNq.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";
|
|
@@ -633,7 +633,7 @@ interface Game3D {
|
|
|
633
633
|
* make "edit the scene" mean "edit wherever the simulation happens to be",
|
|
634
634
|
* so the source is kept beside the game and handed over unchanged.
|
|
635
635
|
*/
|
|
636
|
-
sourceJson: SceneJson;
|
|
636
|
+
sourceJson: SceneJson$1;
|
|
637
637
|
renderer: GameRenderer;
|
|
638
638
|
physics: Physics3D | null;
|
|
639
639
|
/**
|
|
@@ -931,6 +931,91 @@ declare class Environment3D {
|
|
|
931
931
|
*/
|
|
932
932
|
declare function setEnvironment3D(engine: Engine, patch: JsonObject): void;
|
|
933
933
|
//#endregion
|
|
934
|
+
//#region src/3d/model-verdict.d.ts
|
|
935
|
+
/**
|
|
936
|
+
* What a GLB will DO in an incanto scene, judged from what is in the file.
|
|
937
|
+
*
|
|
938
|
+
* Finding an asset is somebody else's job and they do it better: the agent8
|
|
939
|
+
* service has a vector search over the same library, and matching "a stocky
|
|
940
|
+
* dwarf blacksmith" to a URL is not something a filename filter should attempt.
|
|
941
|
+
* What no search can answer is whether the thing it found will work HERE —
|
|
942
|
+
*
|
|
943
|
+
* will the library's animation clips play on it? (bone NAMES decide, and a
|
|
944
|
+
* rig that does not match keeps its bind pose with only a console warning)
|
|
945
|
+
* how tall is it? (a model authored in centimetres dwarfs the scene)
|
|
946
|
+
* does it stand on its own origin? (`monster_trumble_mega` sits ten units
|
|
947
|
+
* above its root, so a node at [0,0,0] puts it in the sky — `targetHeight`
|
|
948
|
+
* normalises SIZE, not position, which cost a day of this repo's time)
|
|
949
|
+
*
|
|
950
|
+
* Every one of those is in the file, and every one of them was learned the
|
|
951
|
+
* expensive way. Pure arithmetic over an already-parsed report: no three, no
|
|
952
|
+
* network, no GPU.
|
|
953
|
+
*/
|
|
954
|
+
/** The parts of `incanto-model`'s report this judges. */
|
|
955
|
+
interface ModelFacts {
|
|
956
|
+
nodes: Array<{
|
|
957
|
+
name: string;
|
|
958
|
+
}>;
|
|
959
|
+
bbox: {
|
|
960
|
+
size: [number, number, number];
|
|
961
|
+
center: [number, number, number];
|
|
962
|
+
min: [number, number, number];
|
|
963
|
+
max: [number, number, number];
|
|
964
|
+
} | null;
|
|
965
|
+
animations: Array<{
|
|
966
|
+
name: string;
|
|
967
|
+
duration: number;
|
|
968
|
+
}>;
|
|
969
|
+
/** Meshes in the file. Zero means there is nothing to draw — see `kind`. */
|
|
970
|
+
meshes: number;
|
|
971
|
+
/** glTF skins. Zero means a prop, not a character. */
|
|
972
|
+
skins: number;
|
|
973
|
+
vrm: {
|
|
974
|
+
humanBones: number;
|
|
975
|
+
} | null;
|
|
976
|
+
}
|
|
977
|
+
type Rig = "mixamorig" | "vrm-humanoid" | "other" | "none";
|
|
978
|
+
interface SceneJson {
|
|
979
|
+
assets: Record<string, {
|
|
980
|
+
type: "model" | "animation";
|
|
981
|
+
url: string;
|
|
982
|
+
}>;
|
|
983
|
+
/** Null for a clip: it is played BY a model, it is not one. */
|
|
984
|
+
node: {
|
|
985
|
+
name: string;
|
|
986
|
+
type: "ModelInstance3D";
|
|
987
|
+
props: Record<string, unknown>;
|
|
988
|
+
} | null;
|
|
989
|
+
}
|
|
990
|
+
interface ModelVerdict {
|
|
991
|
+
/**
|
|
992
|
+
* What the file IS. A clip is a `.glb` with a rig and no mesh — declared as a
|
|
993
|
+
* model it renders nothing at all, with no error, and every other signal
|
|
994
|
+
* about it says "character".
|
|
995
|
+
*/
|
|
996
|
+
kind: "model" | "animation";
|
|
997
|
+
rig: Rig;
|
|
998
|
+
/** The `3d/animations` shelf plays on this as-is. */
|
|
999
|
+
takesLibraryClips: boolean;
|
|
1000
|
+
/** Height in the file's own units (0 when there is no geometry to measure). */
|
|
1001
|
+
height: number;
|
|
1002
|
+
/** Where the model sits relative to its own origin: bottom-centre, in units. */
|
|
1003
|
+
originOffset: [number, number, number];
|
|
1004
|
+
/** Standing on its own origin — a node at [0,0,0] puts its feet on the floor. */
|
|
1005
|
+
groundedAtOrigin: boolean;
|
|
1006
|
+
/** Clip names baked into the file itself (playable by name, no asset entry). */
|
|
1007
|
+
embeddedClips: string[];
|
|
1008
|
+
sceneJson: SceneJson | null;
|
|
1009
|
+
}
|
|
1010
|
+
interface VerdictTarget {
|
|
1011
|
+
url: string;
|
|
1012
|
+
/** Scene asset key. Also names the node. */
|
|
1013
|
+
key: string;
|
|
1014
|
+
}
|
|
1015
|
+
declare function modelVerdict(facts: ModelFacts, target?: VerdictTarget): ModelVerdict;
|
|
1016
|
+
/** The verdict as sentences: what to do, and what will bite if you do not. */
|
|
1017
|
+
declare function verdictText(v: ModelVerdict): string;
|
|
1018
|
+
//#endregion
|
|
934
1019
|
//#region src/3d/nodes/billboard-3d.d.ts
|
|
935
1020
|
/** Valid `Billboard3D.mode` values — drives the prop options AND validateJson. */
|
|
936
1021
|
declare const BILLBOARD_GROUP_MODES: readonly ["screen", "y", "none"];
|
|
@@ -3621,4 +3706,4 @@ declare function createNavDebugNode(nav: TerrainNav, opts?: {
|
|
|
3621
3706
|
name?: string;
|
|
3622
3707
|
}): InstancedMesh3D;
|
|
3623
3708
|
//#endregion
|
|
3624
|
-
export { Area3D, AssetStore3D, type BedSampler, Billboard3D, type BillboardGroupMode, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, type CreateGame3DOptions, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, type DownhillTraceOptions, Environment3D, type Environment3DConfig, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, type FlowerVariety, Flowers3D, type FogEnvironment, Foliage3D, type FoliageKind, type FoliageStyle, type FrameDiff, type FrameSignature, type FrameStats, type FrameStatsOptions, type Game3D, type GridCell, type HeightSampler, type Heightmap, type HeightmapOptions, InstancedMesh3D, Joint3D, type JointType3D, LoftMesh3D, type LoftSection, MeshInstance3D, type MeshKind, type MeshMaterialProps, type ModelEntry, ModelInstance3D, Node3D, OmniLight3D, Particles3D, Physics3D, type Physics3DOptions, PhysicsBody3D, QUARTER_PITCH, type RenderContext3D, type RenderHook3D, Renderer3D, type Renderer3DOptions, type RigView, RigidBody3D, type Ripple, River3D, type RiverCarveOptions, type RiverCoverageGap, type RiverHit, type RiverRing, type RiverRingOptions, SIGNATURE_GRID, type ShadowsEnvironment, type SkyEnvironment, StaticBody3D, type SunConsumer3D, type SyncOptions, type SyncResult, TERRAIN_THEMES, Terrain3D, type TerrainLayer, type TerrainNav, type TerrainNavOptions, type TerrainTheme, TextureCache3D, type TextureSpec, Trail3D, Tree3D, type TreeTier, type TreeType, VOXEL_PALETTE, type VoxelBlock, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, diffSignatures, diffText, enablePhysics3D, findRiverCoverageGaps, frameSignature, frameStats, frameText, horizonColorFromSky, keyboardIntensity, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath };
|
|
3709
|
+
export { Area3D, AssetStore3D, type BedSampler, Billboard3D, type BillboardGroupMode, BoneAttachment3D, BoneLookAt3D, Camera3D, CharacterBody3D, CharacterController3D, type CreateGame3DOptions, DEFAULT_TERRAIN_TEXTURE_BASE, DirectionalLight3D, type DownhillTraceOptions, Environment3D, type Environment3DConfig, DENSITY_PRESETS as FLOWER_DENSITY_PRESETS, FLOWER_VARIETIES, type FlowerVariety, Flowers3D, type FogEnvironment, Foliage3D, type FoliageKind, type FoliageStyle, type FrameDiff, type FrameSignature, type FrameStats, type FrameStatsOptions, type Game3D, type GridCell, type HeightSampler, type Heightmap, type HeightmapOptions, InstancedMesh3D, Joint3D, type JointType3D, LoftMesh3D, type LoftSection, MeshInstance3D, type MeshKind, type MeshMaterialProps, type ModelEntry, type ModelFacts, ModelInstance3D, type ModelVerdict, Node3D, OmniLight3D, Particles3D, Physics3D, type Physics3DOptions, PhysicsBody3D, QUARTER_PITCH, type RenderContext3D, type RenderHook3D, Renderer3D, type Renderer3DOptions, type Rig, type RigView, RigidBody3D, type Ripple, River3D, type RiverCarveOptions, type RiverCoverageGap, type RiverHit, type RiverRing, type RiverRingOptions, SIGNATURE_GRID, type SceneJson, type ShadowsEnvironment, type SkyEnvironment, StaticBody3D, type SunConsumer3D, type SyncOptions, type SyncResult, TERRAIN_THEMES, Terrain3D, type TerrainLayer, type TerrainNav, type TerrainNavOptions, type TerrainTheme, TextureCache3D, type TextureSpec, Trail3D, Tree3D, type TreeTier, type TreeType, VOXEL_PALETTE, type VoxelBlock, VoxelGrid3D, WATER_CUTOUT_MAX, WATER_MAX_RIPPLES, Water3D, WaterCutout3D, acquireOwnTexture, acquireTexture, buildHeightmap, buildRiverRings, buildTerrainNav, cameraRelative, createGame3D, createNavDebugNode, diffSignatures, diffText, enablePhysics3D, findRiverCoverageGaps, frameSignature, frameStats, frameText, horizonColorFromSky, keyboardIntensity, modelVerdict, movementState, parseEnvironment3D, projectToRiver, registerNodes3D, resolveFlowerDensity, rigPose, riverCarveChannels, riverStepFor, setEnvironment3D, smoothCourse, splatWeights, sunDirectionFromElevationAzimuth, sunDirectionFromSky, syncTree, terrainThemeLayers, traceDownhillPath, verdictText };
|