incanto 0.69.0 → 0.71.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 (147) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1088 -882
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.js +0 -10958
@@ -85,3 +85,25 @@ export function behaviorsHelp(file, why) {
85
85
  : '\nPass the file that exports your Behavior subclasses, e.g. src/behaviors.ts.')
86
86
  );
87
87
  }
88
+
89
+ /**
90
+ * The files a `--behaviors` value names — one, or several.
91
+ *
92
+ * The flag took exactly one module and every bin's help said `FILE`, while the
93
+ * comment two functions up already knew that authors "split their behaviours
94
+ * across files — which is most of them, eventually". A game whose bosses live
95
+ * in `boss.ts` and whose scene table lives in `scenes.ts` had one way through:
96
+ * write a barrel that re-exports both, for the CLI's benefit and nothing
97
+ * else's. Comma-separate them instead, or repeat the flag.
98
+ *
99
+ * Splitting on the comma is safe on every platform this ships to: a comma is
100
+ * legal in a POSIX filename and effectively never used in one, and the repeated
101
+ * form is there for anyone who has managed it.
102
+ */
103
+ export function behaviorFiles(value) {
104
+ const many = Array.isArray(value) ? value : [value];
105
+ return many
106
+ .flatMap((v) => String(v ?? '').split(','))
107
+ .map((f) => f.trim())
108
+ .filter(Boolean);
109
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Read a JSON file the way every other tool does: without the BOM.
3
+ *
4
+ * A UTF-8 byte order mark is legal in a UTF-8 file and meaningless in it, and
5
+ * `JSON.parse` refuses it. Windows PowerShell 5.1's `Set-Content` writes one by
6
+ * default, Notepad offers it, and several editors add it on "save as UTF-8" —
7
+ * so a scene written by a perfectly ordinary tool came back as
8
+ *
9
+ * [BAD_JSON] Unexpected token '', "{ "format"... is not valid JSON
10
+ *
11
+ * about a character that is ZERO WIDTH. The author sees valid JSON, an error
12
+ * pointing at nothing, and no way forward. Node's own `require()` of a .json
13
+ * file has stripped it for a decade; `JSON.parse` is the one door that does not.
14
+ *
15
+ * Every bin reads project JSON through here now, so the answer is the same
16
+ * wherever a file arrives from.
17
+ */
18
+ import { readFileSync } from 'node:fs';
19
+
20
+ /** Strip a leading UTF-8 BOM. Everything else is left exactly as written. */
21
+ export function stripBom(text) {
22
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
23
+ }
24
+
25
+ /** `JSON.parse` of a file, BOM and all. Throws the parser's own error. */
26
+ export function readJson(file) {
27
+ return JSON.parse(stripBom(readFileSync(file, 'utf-8')));
28
+ }
@@ -12,15 +12,16 @@
12
12
  * External art still works the usual way (any URL in scene assets{}) — this
13
13
  * is the zero-setup starting set.
14
14
  */
15
- import { copyFileSync, mkdirSync, readFileSync } from 'node:fs';
15
+ import { copyFileSync, mkdirSync } from 'node:fs';
16
16
  import { basename, dirname, join, resolve } from 'node:path';
17
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
18
+ import { readJson } from './_read-json.mjs';
18
19
 
19
20
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
20
21
  const ASSETS = join(PKG, 'assets');
21
22
 
22
23
  function catalog() {
23
- return JSON.parse(readFileSync(join(ASSETS, 'catalog.json'), 'utf-8'));
24
+ return readJson(join(ASSETS, 'catalog.json'));
24
25
  }
25
26
 
26
27
  const [command, ...rest] = process.argv.slice(2);
@@ -76,7 +77,7 @@ if (command === 'list') {
76
77
  if (command === 'info') {
77
78
  console.log(JSON.stringify(entry, null, 2));
78
79
  if (entry.animation) {
79
- const meta = JSON.parse(readFileSync(join(ASSETS, entry.animation), 'utf-8'));
80
+ const meta = readJson(join(ASSETS, entry.animation));
80
81
  console.log('\nanimations:', Object.keys(meta.animations ?? {}).join(', '));
81
82
  }
82
83
  process.exitCode = 0; // see the list note — exit() truncates piped stdout
@@ -98,7 +99,7 @@ if (command === 'list') {
98
99
  const { spriteFromLibraryMeta } = await import(
99
100
  pathToFileURL(join(PKG, 'dist', '2d.js')).href
100
101
  );
101
- const meta = JSON.parse(readFileSync(join(ASSETS, entry.animation), 'utf-8'));
102
+ const meta = readJson(join(ASSETS, entry.animation));
102
103
  const { asset, props } = spriteFromLibraryMeta(meta, { url, assetKey: entry.name });
103
104
  console.log(`\nscene assets entry:\n "${entry.name}": ${JSON.stringify(asset)}`);
104
105
  console.log(
@@ -14,11 +14,12 @@
14
14
  * that needs no TypeScript. Sub-scene `instance` paths resolve relative to
15
15
  * each scene file.
16
16
  */
17
- import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
17
+ import { existsSync, lstatSync, readdirSync, statSync } from 'node:fs';
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
+ import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
22
+ import { readJson } from './_read-json.mjs';
22
23
 
23
24
  const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
24
25
 
@@ -38,7 +39,8 @@ function parseArgs(argv) {
38
39
  const a = argv[i];
39
40
  if (a === '--json') args.json = true;
40
41
  else if (a === '--strict-behaviors') args.strict = true;
41
- else if (a === '--behaviors') args.behaviors = argv[++i];
42
+ // Repeatable: behaviours legitimately live in more than one file.
43
+ else if (a === '--behaviors') args.behaviors = [...(args.behaviors ?? []), argv[++i]];
42
44
  else if (a === '--strict-warnings') args.strictWarnings = true;
43
45
  else if (a === '--help' || a === '-h') args.help = true;
44
46
  else if (a.startsWith('--')) {
@@ -51,7 +53,7 @@ function parseArgs(argv) {
51
53
 
52
54
  const args = parseArgs(process.argv.slice(2));
53
55
  if (args.help || args.invalid) {
54
- console.log(`Usage: npx incanto check [files|dirs...] [--json] [--behaviors FILE] [--strict-behaviors] [--strict-warnings]
56
+ console.log(`Usage: npx incanto check [files|dirs...] [--json] [--behaviors FILE[,FILE...]] [--strict-behaviors] [--strict-warnings]
55
57
 
56
58
  Validates *.scene.json files headlessly with the installed engine.
57
59
  With no arguments, scans the current directory recursively
@@ -86,12 +88,31 @@ function collectScenes(entry) {
86
88
  */
87
89
  function missingArt(file, json) {
88
90
  const assets = json && typeof json === 'object' ? json.assets : null;
89
- if (!assets || typeof assets !== 'object') return [];
90
91
  const sceneDir = dirname(file);
91
92
  const root = projectRoot(sceneDir);
92
93
  const out = [];
93
- for (const [key, decl] of Object.entries(assets)) {
94
- const url = decl && typeof decl === 'object' ? decl.url : null;
94
+ /*
95
+ * A url is named in TWO places, and this only ever looked at one.
96
+ *
97
+ * `assets{}` is where the 2D sprite props and `ModelInstance3D` point; the
98
+ * 3D texture props, `AudioPlayer.src`, `material.map` and a raw-url
99
+ * `UiImage.src` take the url DIRECTLY, and for three of them a url is the
100
+ * only spelling accepted. None of those was ever checked, while
101
+ * `incanto-verify` reported "its assets resolve" over the top of them.
102
+ */
103
+ const named = [
104
+ ...Object.entries(assets && typeof assets === 'object' ? assets : {}).map(([key, decl]) => ({
105
+ what: `$${key}`,
106
+ kind: decl && typeof decl === 'object' ? decl.type : '',
107
+ url: decl && typeof decl === 'object' ? decl.url : null,
108
+ })),
109
+ ...sceneAssetUrls(json).map((u) => ({
110
+ what: `${u.path}.${u.prop}`,
111
+ kind: u.kind,
112
+ url: u.url,
113
+ })),
114
+ ];
115
+ for (const { what, kind, url } of named) {
95
116
  if (typeof url !== 'string' || url === '') continue;
96
117
  // Remote and inline are not this command's business to guess about.
97
118
  if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//')) continue;
@@ -113,9 +134,9 @@ function missingArt(file, json) {
113
134
  // warned about art that works.
114
135
  if (resolvesAsPackageAsset(url, sceneDir)) continue;
115
136
  out.push(
116
- `$${key} → ${url} is not in the project ` +
137
+ `${what} → ${url} is not in the project ` +
117
138
  `(looked in ${relativeish(root, join(root, 'public'))}, ${relativeish(root, root)}` +
118
- `, and beside the scene). It will draw nothing.`,
139
+ `, and beside the scene). It will ${kind === 'audio' ? 'be silent' : 'draw nothing'}.`,
119
140
  );
120
141
  }
121
142
  return out;
@@ -168,31 +189,37 @@ if (files.length === 0) {
168
189
  process.exit(1);
169
190
  }
170
191
 
171
- const { auditScene, instancedScenes, validateScene } = await import(
172
- pathToFileURL(join(DIST, 'test.js')).href
173
- );
192
+ const { auditScene, instancedScenes, registerAllNodes, sceneAssetUrls, validateScene } =
193
+ await import(pathToFileURL(join(DIST, 'test.js')).href);
194
+ // `sceneAssetUrls` reads the node registry to know which props name a resource.
195
+ registerAllNodes();
174
196
 
175
197
  /** Your Behavior subclasses, when you name a file — same contract as the other bins. */
176
198
  const behaviors = {};
177
- if (args.behaviors) {
199
+ const behaviorSources = behaviorFiles(args.behaviors);
200
+ if (behaviorSources.length > 0) {
178
201
  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;
202
+ for (const file of behaviorSources) {
203
+ let mod;
204
+ try {
205
+ mod = await import(pathToFileURL(resolve(file)).href);
206
+ } catch (e) {
207
+ const why = e?.message ?? String(e);
208
+ // node cannot import the pattern this package teaches (a JSON import
209
+ // without an attribute, an extensionless relative path); bun can. Same
210
+ // hand-off as `incanto-playtest`.
211
+ reexecUnderBun(import.meta.url, why);
212
+ console.error(behaviorsHelp(file, why));
213
+ process.exit(1);
214
+ }
215
+ for (const [name, value] of Object.entries(mod)) {
216
+ if (typeof value === 'function' && value.prototype instanceof Behavior) {
217
+ behaviors[name] = value;
218
+ }
219
+ }
193
220
  }
194
221
  if (Object.keys(behaviors).length === 0) {
195
- console.error(`warning: no Behavior subclasses exported from ${args.behaviors}`);
222
+ console.error(`warning: no Behavior subclasses exported from ${behaviorSources.join(', ')}`);
196
223
  }
197
224
  }
198
225
 
@@ -223,7 +250,7 @@ const declaredAssets = {};
223
250
  const corpus = [];
224
251
  for (const file of files) {
225
252
  try {
226
- const doc = JSON.parse(readFileSync(file, 'utf-8'));
253
+ const doc = readJson(file);
227
254
  corpus.push({ path: resolve(file), json: doc });
228
255
  for (const [locale, entries] of Object.entries(doc?.strings ?? {})) {
229
256
  if (typeof entries !== 'object' || entries === null) continue;
@@ -242,7 +269,7 @@ const instanced = instancedScenes(corpus, (from, ref) => resolve(dirname(from),
242
269
  const results = files.map((file) => {
243
270
  let json;
244
271
  try {
245
- json = JSON.parse(readFileSync(file, 'utf-8'));
272
+ json = readJson(file);
246
273
  } catch (e) {
247
274
  return { file, ok: false, code: 'BAD_JSON', message: String(e?.message ?? e) };
248
275
  }
@@ -262,7 +289,7 @@ const results = files.map((file) => {
262
289
  */
263
290
  behaviors,
264
291
  declaredAssets,
265
- resolveScene: (p) => JSON.parse(readFileSync(resolve(dirname(file), p), 'utf-8')),
292
+ resolveScene: (p) => readJson(resolve(dirname(file), p)),
266
293
  });
267
294
  if (res.ok) {
268
295
  return {
@@ -31,9 +31,11 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
31
31
  import { createServer } from 'node:http';
32
32
  import { basename, dirname, extname, join, normalize, relative, resolve, sep } from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
+ import { readJson } from './_read-json.mjs';
34
35
 
35
36
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
36
37
  const EDITOR_DIR = join(PKG_ROOT, 'editor');
38
+ const ASSETS_DIR = join(PKG_ROOT, 'assets');
37
39
 
38
40
  // Biome-compatible JSON layout — same algorithm as the editor's
39
41
  // src/format-json.ts (inline when the one-line form fits 80 cols).
@@ -100,10 +102,39 @@ const MODE = args.input ? 'single' : 'project';
100
102
  const ROOT = resolve('.');
101
103
  const INPUT = args.input ? resolve(args.input) : null;
102
104
  const OUTPUT = args.input ? resolve(args.output ?? args.input) : null;
103
- const VERSION = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
105
+ const VERSION = readJson(join(PKG_ROOT, 'package.json')).version;
104
106
 
105
107
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage']);
106
108
 
109
+ /*
110
+ * A named scene that is not there is a typo, and the banner used to congratulate
111
+ * you on it:
112
+ *
113
+ * $ incanto-editor typo.scene.json
114
+ * incanto-editor v0.69.0
115
+ * scene: /private/tmp/ed/typo.scene.json
116
+ * url: http://127.0.0.1:5399/
117
+ *
118
+ * The server started, the terminal reported success, and the failure waited in
119
+ * the browser — where it reads as the editor being broken rather than as the
120
+ * path being wrong. Project mode is the mode that CREATES scenes; naming one is
121
+ * a request to edit a file that exists.
122
+ */
123
+ if (MODE === 'single' && !existsSync(INPUT)) {
124
+ console.error(`no such scene: ${INPUT}`);
125
+ const near = discoverScenes()
126
+ .filter((f) => !f.notScene)
127
+ .slice(0, 5);
128
+ if (near.length > 0) {
129
+ console.error('\nscenes in this project:');
130
+ for (const f of near) console.error(` ${f.rel}`);
131
+ console.error('\nor run `incanto-editor` with no file to browse them all.');
132
+ } else {
133
+ console.error('\nrun `incanto-editor` with no file to browse this project.');
134
+ }
135
+ process.exit(1);
136
+ }
137
+
107
138
  // --- agent8 asset library -------------------------------------------------
108
139
  const LIBRARY_URL =
109
140
  process.env.INCANTO_LIBRARY_URL ?? 'https://agent8-backend.verse8.io/api/resources';
@@ -212,7 +243,7 @@ function projectDimension() {
212
243
  let d3 = 0;
213
244
  for (const entry of discoverScenes()) {
214
245
  try {
215
- const json = JSON.parse(readFileSync(join(ROOT, entry.rel ?? entry), 'utf8'));
246
+ const json = readJson(join(ROOT, entry.rel ?? entry));
216
247
  if (json?.dimension === '3d') d3 += 1;
217
248
  else if (json?.dimension === '2d') d2 += 1;
218
249
  } catch {
@@ -294,7 +325,7 @@ function inspectJson(abs, size) {
294
325
  if (size > 16_000_000) return { notScene: 'too large to check without opening it' };
295
326
  let parsed;
296
327
  try {
297
- parsed = JSON.parse(readFileSync(abs, 'utf-8'));
328
+ parsed = readJson(abs);
298
329
  } catch (error) {
299
330
  return { notScene: `not valid JSON: ${String(error.message ?? error)}` };
300
331
  }
@@ -334,6 +365,15 @@ const MIME = {
334
365
  '.json': 'application/json; charset=utf-8',
335
366
  '.svg': 'image/svg+xml',
336
367
  '.png': 'image/png',
368
+ '.jpg': 'image/jpeg',
369
+ '.jpeg': 'image/jpeg',
370
+ '.webp': 'image/webp',
371
+ '.gif': 'image/gif',
372
+ '.mp3': 'audio/mpeg',
373
+ '.ogg': 'audio/ogg',
374
+ '.wav': 'audio/wav',
375
+ '.glb': 'model/gltf-binary',
376
+ '.gltf': 'model/gltf+json',
337
377
  '.wasm': 'application/wasm',
338
378
  };
339
379
 
@@ -521,6 +561,46 @@ const server = createServer((req, res) => {
521
561
  }),
522
562
  );
523
563
  }
564
+ /*
565
+ * REFUSE to overwrite a file that is not already a scene.
566
+ *
567
+ * The body check above asks "is this a scene"; nothing asked what it
568
+ * was about to land on. `?file=` accepts any `.json` under the project
569
+ * on purpose — the browser lists every JSON and the LOAD decides, which
570
+ * is how a scene saved as `level.json` still opens — so the write path
571
+ * was one query parameter away from destroying a manifest:
572
+ *
573
+ * $ curl -X PUT -d '{…a valid scene…}' '…/api/scene?file=package.json'
574
+ * {"ok":true,"output":"/private/tmp/repro/package.json"} [200]
575
+ * $ cat package.json
576
+ * {"format":1,"type":"scene",…}
577
+ *
578
+ * And the GET on the same server already refuses a non-scene WITH the
579
+ * reason, so the two halves of one endpoint disagreed about the rule.
580
+ *
581
+ * A file that does not exist yet is fine: that is `create`.
582
+ */
583
+ if (existsSync(target.output)) {
584
+ let existing = null;
585
+ try {
586
+ existing = readJson(target.output);
587
+ } catch {
588
+ // Unparseable: not a scene, and not ours to replace either.
589
+ }
590
+ const already =
591
+ existing === null ? { ok: false, why: 'it is not JSON' } : sceneFacts(existing);
592
+ if (!already.ok) {
593
+ return send(
594
+ res,
595
+ 409,
596
+ JSON.stringify({
597
+ error:
598
+ `refusing to overwrite ${target.output} — ${already.why}. ` +
599
+ 'The editor only replaces scenes, and only creates new files.',
600
+ }),
601
+ );
602
+ }
603
+ }
524
604
  try {
525
605
  // Write the client's bytes verbatim — the editor formats scenes
526
606
  // biome-compatibly; re-stringifying here would undo that.
@@ -535,6 +615,39 @@ const server = createServer((req, res) => {
535
615
  return send(res, 405, JSON.stringify({ error: 'method not allowed' }));
536
616
  }
537
617
 
618
+ /*
619
+ * The BUILT-IN art, at the path the scenes already name.
620
+ *
621
+ * `incanto-assets` prints `incanto/assets/items/coin.png` as an asset's url
622
+ * and three shipped 2D templates carry exactly that — a BUNDLER specifier,
623
+ * which the game's vite resolves and a browser cannot. In the game the
624
+ * template's `main.ts` overwrites each url with the import; the editor loads
625
+ * the scene JSON as written, so it asked this server for
626
+ * `/incanto/assets/characters/medieval-knight.png` and got 404. Every sprite
627
+ * in `platformer-2d`, `star-survivor` and `topdown-rpg` was a blank quad in
628
+ * the composer they ship with.
629
+ *
630
+ * Serving the package's own `assets/` here makes the written url resolve, so
631
+ * the art is live in the editor without any scene change and without the
632
+ * runtime injection having to know about the editor.
633
+ */
634
+ if (url.pathname.startsWith('/incanto/assets/')) {
635
+ const asset = normalize(join(ASSETS_DIR, url.pathname.slice('/incanto/assets/'.length)));
636
+ if (!asset.startsWith(ASSETS_DIR + sep)) {
637
+ return send(res, 403, JSON.stringify({ error: 'forbidden' }));
638
+ }
639
+ try {
640
+ return send(
641
+ res,
642
+ 200,
643
+ readFileSync(asset),
644
+ MIME[extname(asset)] ?? 'application/octet-stream',
645
+ );
646
+ } catch {
647
+ return send(res, 404, JSON.stringify({ error: `no built-in asset: ${url.pathname}` }));
648
+ }
649
+ }
650
+
538
651
  // static editor assets (path-traversal guarded)
539
652
  const rel = url.pathname === '/' ? '/index.html' : url.pathname;
540
653
  const file = normalize(join(EDITOR_DIR, rel));
@@ -559,9 +672,19 @@ server.listen(args.port, args.host, () => {
559
672
  if (MODE === 'single') {
560
673
  console.log(` scene: ${INPUT}${OUTPUT === INPUT ? '' : `\n output: ${OUTPUT}`}`);
561
674
  } else {
562
- const scenes = discoverScenes();
675
+ /*
676
+ * `discoverScenes()` returns every `.json` in the project — that is the
677
+ * point, because the BROWSER lists them all and marks which ones are
678
+ * scenes. Counting the list and calling the total "scene files" made the
679
+ * banner say `4 scene files` about a project holding one scene,
680
+ * `package.json`, `tsconfig.json` and `biome.json`.
681
+ */
682
+ const found = discoverScenes();
683
+ const scenes = found.filter((f) => !f.notScene);
684
+ const other = found.length - scenes.length;
563
685
  console.log(
564
- ` project: ${ROOT} (${scenes.length} scene file${scenes.length === 1 ? '' : 's'})`,
686
+ ` project: ${ROOT} (${scenes.length} scene${scenes.length === 1 ? '' : 's'}` +
687
+ `${other > 0 ? `, ${other} other .json file${other === 1 ? '' : 's'} listed` : ''})`,
565
688
  );
566
689
  }
567
690
  console.log(
@@ -15,9 +15,10 @@
15
15
  * generateScatter is library-only (`import {generateScatter} from
16
16
  * 'incanto/env'`) — its item templates don't fit CLI flags.
17
17
  */
18
- import { readFileSync, writeFileSync } from 'node:fs';
18
+ import { writeFileSync } from 'node:fs';
19
19
  import { dirname, join, resolve } from 'node:path';
20
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
+ import { readJson } from './_read-json.mjs';
21
22
 
22
23
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
23
24
 
@@ -128,7 +129,7 @@ const countNodes = (n) => 1 + (n.children ?? []).reduce((sum, c) => sum + countN
128
129
  if (flags.has('--into')) {
129
130
  const file = resolve(flags.get('--into'));
130
131
  const at = flags.get('--at');
131
- const scene = JSON.parse(readFileSync(file, 'utf-8'));
132
+ const scene = readJson(file);
132
133
  const out = env.insertIntoScene(scene, node, at);
133
134
  writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
134
135
  console.log(
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
2
+
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  /**
5
5
  * incanto-feel — what your controls actually FEEL like, as numbers.
@@ -21,7 +21,8 @@ import { dirname, join, resolve } from 'node:path';
21
21
  * wrong for most games.
22
22
  */
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
24
- import { behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
24
+ import { behaviorFiles, behaviorsHelp, reexecUnderBun } from './_behaviors-loader.mjs';
25
+ import { readJson } from './_read-json.mjs';
25
26
 
26
27
  const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
27
28
 
@@ -29,13 +30,19 @@ function parseArgs(argv) {
29
30
  const args = {};
30
31
  for (let i = 0; i < argv.length; i++) {
31
32
  const a = argv[i];
32
- if (a === '--behaviors') args.behaviors = argv[++i];
33
+ // Repeatable: behaviours legitimately live in more than one file.
34
+ if (a === '--behaviors') args.behaviors = [...(args.behaviors ?? []), argv[++i]];
33
35
  else if (a === '--move') args.move = argv[++i];
34
36
  else if (a === '--jump') args.jump = argv[++i];
35
37
  else if (a === '--json') args.json = true;
36
38
  else if (a === '--help' || a === '-h') args.help = true;
37
39
  else if (a.startsWith('--')) {
40
+ // `invalid`, not `help`: printing usage and exiting 0 told an agent its
41
+ // typo was a request. `--runs=4`, `--behaviours`, `--no-replay` all landed
42
+ // here and looked like success — and with `--json` the machine path was
43
+ // literally silent (0 bytes on stdout).
38
44
  console.error(`unknown flag: ${a}`);
45
+ args.invalid = true;
39
46
  args.help = true;
40
47
  } else if (!args.scene) args.scene = a;
41
48
  }
@@ -47,10 +54,13 @@ if (args.help || !args.scene) {
47
54
  console.error(`Usage: incanto-feel <scene.json> [options]
48
55
 
49
56
  --behaviors FILE your Behavior subclasses (.ts works on node >= 23.6 / bun)
50
- --move ACTION the vector action to move with (default: the first vector2)
57
+ comma-separate or repeat for several files
58
+ --move ACTION the vector action to move with, and ONLY that one
59
+ (default: the first vector2, then every other one, each
60
+ pushed +x/-y/+y/-x until the player moves)
51
61
  --jump ACTION the button to jump with (default: "jump")
52
62
  --json the report as JSON instead of prose`);
53
- process.exit(args.help && args.scene !== undefined ? 0 : 1);
63
+ process.exit(!args.invalid && args.help && args.scene !== undefined ? 0 : 1);
54
64
  }
55
65
 
56
66
  const { facingReport, facingText, feelReport, feelText } = await import(
@@ -59,14 +69,15 @@ const { facingReport, facingText, feelReport, feelText } = await import(
59
69
  const incanto = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
60
70
 
61
71
  const behaviors = {};
62
- if (args.behaviors) {
72
+ const behaviorSources = behaviorFiles(args.behaviors);
73
+ for (const file of behaviorSources) {
63
74
  let mod;
64
75
  try {
65
- mod = await import(pathToFileURL(resolve(args.behaviors)).href);
76
+ mod = await import(pathToFileURL(resolve(file)).href);
66
77
  } catch (e) {
67
78
  const why = e?.message ?? String(e);
68
79
  reexecUnderBun(import.meta.url, why);
69
- console.error(behaviorsHelp(args.behaviors, why));
80
+ console.error(behaviorsHelp(file, why));
70
81
  process.exit(1);
71
82
  }
72
83
  for (const [name, value] of Object.entries(mod)) {
@@ -78,16 +89,16 @@ if (args.behaviors) {
78
89
 
79
90
  const scenePath = resolve(args.scene);
80
91
  const sceneDir = dirname(scenePath);
81
- const sceneJson = JSON.parse(readFileSync(scenePath, 'utf-8'));
92
+ const sceneJson = readJson(scenePath);
82
93
 
83
94
  let report;
84
95
  try {
85
96
  report = await feelReport(sceneJson, {
86
97
  behaviors,
87
- stubMissingBehaviors: !args.behaviors,
98
+ stubMissingBehaviors: behaviorSources.length === 0,
88
99
  ...(args.move ? { moveAction: args.move } : {}),
89
100
  ...(args.jump ? { jumpAction: args.jump } : {}),
90
- resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
101
+ resolveScene: (p) => readJson(resolve(sceneDir, p)),
91
102
  });
92
103
  } catch (e) {
93
104
  console.error(`feel failed: ${e?.message ?? e}`);
@@ -103,8 +114,8 @@ if (!args.json) {
103
114
  try {
104
115
  const facing = await facingReport(sceneJson, {
105
116
  behaviors,
106
- stubMissingBehaviors: !args.behaviors,
107
- resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
117
+ stubMissingBehaviors: behaviorSources.length === 0,
118
+ resolveScene: (p) => readJson(resolve(sceneDir, p)),
108
119
  });
109
120
  if (facing.skin) process.stdout.write(`${facingText(facing)}\n`);
110
121
  } catch {
@@ -75,10 +75,17 @@ running game before capturing — the same words incanto-play takes:
75
75
 
76
76
  incanto-frame --do "vector move 0 1; step 3000; vector move 0 0" --out bridge.png
77
77
  incanto-frame --do "press jump; step 400" --out midair.png
78
+ incanto-frame --do "at %Coin; click; step 300" --out collected.png
78
79
 
79
80
  press/release ACTION · vector ACTION X Y · key CODE down|up
81
+ at X Y | at NODE-PATH · click [left|middle|right] · mouse BUTTON down|up
80
82
  pointer DX DY · step MS (semicolons or newlines separate)
81
83
 
84
+ 'at' puts the CURSOR somewhere — a canvas pixel, or wherever a node is on
85
+ screen — and 'click' presses and releases there. That is the whole input of a
86
+ match-3, a tower defense, a card game or a point-and-click, and this language
87
+ could not say it: 'pointer' is a delta, which is the mouse-LOOK question.
88
+
82
89
  step waits on the real clock: this is the live game, with its own loop.
83
90
 
84
91
 
@@ -140,7 +147,7 @@ if (driveScript) {
140
147
  // Refuse here rather than in the page: a bad command that ran nothing would
141
148
  // hand back the boot screen, and nothing about that frame would say so.
142
149
  console.error(
143
- `${parsed.error}\n commands: press/release ACTION · vector ACTION X Y · key CODE down|up · pointer DX DY · step MS`,
150
+ `${parsed.error}\n commands: press/release ACTION · vector ACTION X Y · key CODE down|up · at X Y | at NODE-PATH · click [left|middle|right] · mouse BUTTON down|up · pointer DX DY · step MS`,
144
151
  );
145
152
  process.exit(1);
146
153
  }