incanto 0.47.0 → 0.49.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 (55) hide show
  1. package/bin/incanto-frame.mjs +58 -1
  2. package/bin/incanto-logs.mjs +127 -0
  3. package/bin/incanto-model.mjs +13 -1
  4. package/bin/incanto-playtest.mjs +7 -0
  5. package/bin/incanto-verify.mjs +41 -2
  6. package/dist/2d.d.ts +21 -12
  7. package/dist/2d.js +57 -3
  8. package/dist/3d.d.ts +38 -1
  9. package/dist/3d.js +114 -4
  10. package/dist/{create-game-CniOiWzN.js → create-game-BgV6UbVA.js} +5 -5
  11. package/dist/{create-game-D16MVIPO.js → create-game-DFBjMetZ.js} +83 -6
  12. package/dist/{duplicate-CRtihGmC.js → duplicate-CI9WF_bg.js} +1 -1
  13. package/dist/{environment-presets-D1b0ydTS.js → environment-presets-CZOH5TY5.js} +15 -20
  14. package/dist/{gameplay-BQOeAid6.js → gameplay-02Btmmjn.js} +80 -9
  15. package/dist/gameplay.d.ts +17 -0
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +139 -1
  18. package/dist/index.js +8 -7
  19. package/dist/{loader-r49nDwB4.js → loader-DwazzlQb.js} +36 -6
  20. package/dist/log-report-lxrQY9cH.js +0 -0
  21. package/dist/net.js +3 -3
  22. package/dist/{physics-2d-BmgXBNDB.js → physics-2d-vyCBfACH.js} +3 -3
  23. package/dist/{physics-3d-CSoGjM8P.js → physics-3d-DpRqw8Mz.js} +4 -4
  24. package/dist/react.js +1 -1
  25. package/dist/{register-R2JTnIMw.js → register-BNPZYJmd.js} +23 -22
  26. package/dist/{register-D651it1J.js → register-CB11yp21.js} +2 -2
  27. package/dist/{register-BSXV8T9F.js → register-uvaZj1KX.js} +62 -2
  28. package/dist/{replay-DilbZgQI.js → replay-C0XJIsO7.js} +1 -1
  29. package/dist/sprite-animation-CMr6f1K2.d.ts +44 -0
  30. package/dist/{particle-sim-Bw7hB93B.js → sprite-animation-D_p28jwU.js} +63 -1
  31. package/dist/{src-Ca3oV1fe.js → src-DF4gCsqO.js} +1 -1
  32. package/dist/{test-E4-otKqK.js → test-DRna_BQU.js} +210 -30
  33. package/dist/test.d.ts +38 -6
  34. package/dist/test.js +2 -2
  35. package/dist/vite.js +41 -4
  36. package/editor/assets/{agent8-_007gPF8.js → agent8-DCW4TgDt.js} +1 -1
  37. package/editor/assets/{debug-0DI_MJaq.js → debug-RC6qts6S.js} +1 -1
  38. package/editor/assets/{index-B-6eYZEi.js → index-5dEIhvsf.js} +92 -92
  39. package/editor/index.html +1 -1
  40. package/package.json +3 -2
  41. package/schemas/scene.schema.json +10 -3
  42. package/skills/incanto-3d-character.md +13 -2
  43. package/skills/incanto-3d-models.md +40 -0
  44. package/skills/incanto-assets.md +15 -0
  45. package/skills/incanto-building-2d-games.md +57 -5
  46. package/skills/incanto-building-3d-games.md +7 -0
  47. package/skills/incanto-gameplay-behaviors.md +18 -1
  48. package/skills/incanto-hud.md +39 -0
  49. package/skills/incanto-node-reference.md +5 -3
  50. package/skills/incanto-physics-and-input.md +1 -1
  51. package/skills/incanto-playtesting.md +9 -2
  52. package/skills/incanto-verifying-your-game.md +67 -0
  53. package/templates-app/beacon-isle-3d/package.json +1 -1
  54. package/templates-app/tps-3d/package.json +1 -1
  55. package/templates-app/village-quest-3d/package.json +1 -1
@@ -26,6 +26,7 @@ const { listeningPorts, parseProcNetTcp } = await import(
26
26
  pathToFileURL(join(PKG, 'dist', 'vite.js')).href
27
27
  );
28
28
  const { frameText, diffText } = await import(pathToFileURL(join(PKG, 'dist', '3d.js')).href);
29
+ const { parseDrive } = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
29
30
 
30
31
  const args = process.argv.slice(2);
31
32
  const asJson = args.includes('--json');
@@ -39,6 +40,8 @@ const flag = (name) => {
39
40
  const gridArg = flag('--grid');
40
41
  const outArg = flag('--out');
41
42
  const sizeArg = flag('--size');
43
+ const driveArg = flag('--do');
44
+ const driveFile = flag('--do-file');
42
45
  const remember = flag('--remember');
43
46
  const diff = flag('--diff');
44
47
  const threshold = flag('--threshold');
@@ -49,6 +52,8 @@ if (args.includes('--help') || args.includes('-h')) {
49
52
  --grid WxH cells across and down (default 16x9)
50
53
  --out FILE also write the frame as a PNG you can look at
51
54
  --size N longest side of that PNG (default 512)
55
+ --do SCRIPT drive the game first, THEN capture (see below)
56
+ --do-file FILE the same script, from a file
52
57
  --port N skip discovery and use this port
53
58
 
54
59
  --remember [L] keep this frame as the baseline named L (default "last")
@@ -64,6 +69,17 @@ the frame it fills. When that is not enough, look:
64
69
 
65
70
  incanto-frame --out shot.png # then open it, or read it
66
71
 
72
+ Every interesting state in a game is downstream of input, so --do drives the
73
+ running game before capturing — the same words incanto-play takes:
74
+
75
+ incanto-frame --do "vector move 0 1; step 3000; vector move 0 0" --out bridge.png
76
+ incanto-frame --do "press jump; step 400" --out midair.png
77
+
78
+ press/release ACTION · vector ACTION X Y · key CODE down|up
79
+ pointer DX DY · step MS (semicolons or newlines separate)
80
+
81
+ step waits on the real clock: this is the live game, with its own loop.
82
+
67
83
 
68
84
  The report of ONE frame has a floor: a one-pixel seam is a fraction of a level
69
85
  once a cell is averaged, and nothing can tell it apart from a thin rope in the
@@ -111,6 +127,24 @@ function bsdListeningPorts() {
111
127
  }
112
128
  }
113
129
 
130
+ /** The script to run before capturing, from `--do` or `--do-file`. */
131
+ const driveScript = driveFile ? read(driveFile) : driveArg;
132
+ if (driveFile && driveScript === null) {
133
+ console.error(`cannot read ${driveFile}`);
134
+ process.exit(1);
135
+ }
136
+ if (driveScript) {
137
+ const parsed = parseDrive(driveScript);
138
+ if (parsed.error) {
139
+ // Refuse here rather than in the page: a bad command that ran nothing would
140
+ // hand back the boot screen, and nothing about that frame would say so.
141
+ console.error(
142
+ `${parsed.error}\n commands: press/release ACTION · vector ACTION X Y · key CODE down|up · pointer DX DY · step MS`,
143
+ );
144
+ process.exit(1);
145
+ }
146
+ }
147
+
114
148
  const explicit = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : null;
115
149
  const discovered = explicit ? [explicit] : await listeningPorts({ read });
116
150
  const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
@@ -150,12 +184,20 @@ if (!found) {
150
184
  const params = new URLSearchParams();
151
185
  if (gridArg !== null) params.set('grid', gridArg);
152
186
  if (outArg) params.set('image', sizeArg || '512');
187
+ if (driveScript) {
188
+ params.set('drive', driveScript);
189
+ // Tell the server how long to wait: the script runs in real time.
190
+ const totalMs = parseDrive(driveScript).totalMs;
191
+ params.set('driveMs', String(totalMs));
192
+ }
153
193
  if (remember !== null) params.set('remember', remember);
154
194
  if (diff !== null) params.set('diff', diff);
155
195
  if (threshold) params.set('threshold', threshold);
156
196
  const q = params.toString();
157
197
  const res = await fetch(`http://${found.host}:${found.port}/__incanto/frame${q ? `?${q}` : ''}`, {
158
- signal: AbortSignal.timeout(10_000),
198
+ // Outlast the server's own budget, or the CLI gives up on an answer that
199
+ // was on its way.
200
+ signal: AbortSignal.timeout(15_000 + (driveScript ? parseDrive(driveScript).totalMs * 2 : 0)),
159
201
  });
160
202
  const body = await res.json().catch(() => null);
161
203
 
@@ -184,6 +226,18 @@ if (!res.ok || !body?.report) {
184
226
  }
185
227
 
186
228
  const report = body.report;
229
+ // A drive that advanced no frames moved nothing: the inputs landed on a game
230
+ // the browser had frozen, and the frame that came back is the one from before.
231
+ // Saying nothing here would be handing over a picture that quietly lies.
232
+ if (driveScript && report.droveFrames === 0) {
233
+ console.error(
234
+ 'the drive ran but the game never advanced — 0 frames drawn.\n' +
235
+ ' The page is hidden (a window covered by another counts) and the browser\n' +
236
+ ' stops animating it, so the inputs landed on a frozen game and this frame\n' +
237
+ ' is unchanged. Bring the preview to the front and try again.',
238
+ );
239
+ process.exit(1);
240
+ }
187
241
  if (report.error) {
188
242
  console.error(`the page could not capture a frame: ${report.error}`);
189
243
  process.exit(1);
@@ -215,6 +269,9 @@ if (asJson) {
215
269
  );
216
270
  } else {
217
271
  const lines = [frameText(report)];
272
+ if (typeof report.droveFrames === 'number') {
273
+ lines.push(`drove ${report.droveFrames} frames before capturing`);
274
+ }
218
275
  if (wroteImage) lines.push(`wrote ${wroteImage} — open it, or read it`);
219
276
  if (body.diffError === 'no-baseline') {
220
277
  lines.push(
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * incanto-logs — what the RUNNING game is saying.
4
+ *
5
+ * bunx incanto-logs
6
+ * bunx incanto-logs --json
7
+ *
8
+ * `incanto-frame` gets the pixels out of the browser; this gets the WORDS. The
9
+ * engine writes diagnostics for exactly this purpose — a model whose fit came
10
+ * out at 934x, an asset that 404'd, a node that quarantined itself — and until
11
+ * now every one of them reached a human only, in the debug overlay's logs
12
+ * panel. An agent could see a game and not hear it.
13
+ *
14
+ * Unlike a frame this needs no render, so it answers from a tab the browser has
15
+ * stopped drawing.
16
+ *
17
+ * Exit 1 when the game reports something wrong: an error logged, an error
18
+ * swallowed to keep it alive, or an asset that never loaded.
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
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 { listeningPorts } = await import(pathToFileURL(join(PKG, 'dist', 'vite.js')).href);
27
+ const { logText } = await import(pathToFileURL(join(PKG, 'dist', 'index.js')).href);
28
+
29
+ const args = process.argv.slice(2);
30
+ const asJson = args.includes('--json');
31
+ if (args.includes('--help') || args.includes('-h')) {
32
+ console.error(`Usage: incanto-logs [--json] [--port N]
33
+
34
+ What the running game is saying: its warnings and errors, the errors it
35
+ swallowed to keep going, the assets that never loaded, and its frame rate.
36
+
37
+ Run your game's dev server and open the page first — the log buffer is in the
38
+ browser, not in the dev server. This needs no render, so a page the browser has
39
+ stopped drawing still answers.
40
+
41
+ Exit 1 when something is wrong.`);
42
+ process.exit(0);
43
+ }
44
+
45
+ const read = (path) => {
46
+ try {
47
+ return readFileSync(path, 'utf-8');
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /** Ports listening on a machine with no `/proc` (macOS, BSD) — see incanto-frame. */
54
+ function bsdListeningPorts() {
55
+ try {
56
+ const { execFileSync } = createRequire(import.meta.url)('node:child_process');
57
+ const out = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], {
58
+ encoding: 'utf-8',
59
+ timeout: 1500,
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ });
62
+ const ports = new Set();
63
+ for (const line of out.split('\n')) {
64
+ const m = line.match(/:(\d+)\s*\(LISTEN\)/);
65
+ if (m) ports.add(Number(m[1]));
66
+ }
67
+ return [...ports];
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
73
+ const explicit = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : null;
74
+ const discovered = explicit ? [explicit] : await listeningPorts({ read });
75
+ const ports = explicit ? discovered : [...new Set([...bsdListeningPorts(), ...discovered])];
76
+
77
+ let found = null;
78
+ outer: for (const port of ports) {
79
+ for (const host of ['localhost', '127.0.0.1']) {
80
+ try {
81
+ const res = await fetch(`http://${host}:${port}/__incanto/ping`, {
82
+ signal: AbortSignal.timeout(300),
83
+ });
84
+ if (!res.ok) continue;
85
+ const body = await res.json();
86
+ if (body?.incanto) {
87
+ found = { host, port };
88
+ break outer;
89
+ }
90
+ } catch {
91
+ // not listening, not ours, or not answering — next
92
+ }
93
+ }
94
+ }
95
+
96
+ if (!found) {
97
+ console.error(
98
+ 'no incanto dev server found.\n' +
99
+ ' Start your game (the dev server) and try again — the log buffer lives in\n' +
100
+ ' the running page, so there has to be one.',
101
+ );
102
+ process.exit(1);
103
+ }
104
+
105
+ const res = await fetch(`http://${found.host}:${found.port}/__incanto/logs`, {
106
+ signal: AbortSignal.timeout(10_000),
107
+ });
108
+ const body = await res.json().catch(() => null);
109
+
110
+ if (res.status === 504 || body?.error === 'no-page-connected') {
111
+ console.error(
112
+ `the dev server is running on :${found.port}, but no page answered.\n` +
113
+ ' Open the preview in a browser — the log buffer is there, not in the server.',
114
+ );
115
+ process.exit(1);
116
+ }
117
+ if (!res.ok || !body?.report || body.report.error) {
118
+ console.error(
119
+ `could not read the game's logs: ${body?.report?.error ?? body?.error ?? res.status}`,
120
+ );
121
+ process.exit(1);
122
+ }
123
+
124
+ console.log(asJson ? JSON.stringify(body.report, null, 2) : logText(body.report));
125
+ // `exitCode`, never `process.exit()` — stdout to a pipe is written
126
+ // asynchronously and exiting discards what has not flushed.
127
+ process.exitCode = body.report.ok ? 0 : 1;
@@ -353,7 +353,19 @@ if (args.json) {
353
353
  // The verdict is the part you act on, so it goes last — closest to the
354
354
  // prompt, where a reader stops.
355
355
  out.push('', verdictText(verdict));
356
- if (verdict.sceneJson) {
356
+ // A rig that takes the library clips gets the WHOLE character: the model on
357
+ // its own is a statue, and the steps from there (a dynamic body under the
358
+ // controller, the skin fitted to the capsule and dropped to its feet, the
359
+ // clips declared, the input actions that make anything read the keyboard)
360
+ // are documented in four different places and all needed at once.
361
+ if (verdict.characterJson) {
362
+ const { assets, input, node } = verdict.characterJson;
363
+ out.push(
364
+ '',
365
+ 'paste a playable character into your scene:',
366
+ JSON.stringify({ assets, input, node }, null, 2),
367
+ );
368
+ } else if (verdict.sceneJson) {
357
369
  const { assets, node } = verdict.sceneJson;
358
370
  out.push(
359
371
  '',
@@ -34,6 +34,8 @@ function parseArgs(argv) {
34
34
  else if (a === '--runs') args.runs = Number(argv[++i]);
35
35
  else if (a === '--seconds') args.seconds = Number(argv[++i]);
36
36
  else if (a === '--seed') args.seed = Number(argv[++i]);
37
+ else if (a === '--fall-below') args.fallBelow = Number(argv[++i]);
38
+ else if (a === '--reach-radius') args.reachRadius = Number(argv[++i]);
37
39
  else if (a === '--out') args.out = argv[++i];
38
40
  else if (a === '--no-replays') args.noReplays = true;
39
41
  else if (a === '--json') args.json = true;
@@ -53,6 +55,9 @@ if (args.help || !args.scene) {
53
55
  --runs N independent seeded runs (default 20)
54
56
  --seconds N simulated seconds per run before calling it stuck (default 60)
55
57
  --seed N first seed; run i uses seed+i (default 1)
58
+ --fall-below Y left the world past this y (default: 50 under the spawn in
59
+ 3D, 1000 px under it in 2D — 2D counts DOWN the screen)
60
+ --reach-radius R how close counts as "reached" (default 2 in 3D, 32 px in 2D)
56
61
  --behaviors FILE your Behavior subclasses (.ts works on node >= 23.6 / bun)
57
62
  --out DIR where failing replays go (default .incanto/playtest)
58
63
  --no-replays report only, write nothing
@@ -101,6 +106,8 @@ try {
101
106
  seed: args.seed,
102
107
  behaviors,
103
108
  stubMissingBehaviors: !args.behaviors,
109
+ ...(Number.isFinite(args.fallBelow) ? { fallBelow: args.fallBelow } : {}),
110
+ ...(Number.isFinite(args.reachRadius) ? { reachRadius: args.reachRadius } : {}),
104
111
  // sub-scenes resolve relative to the scene file, as the loader does
105
112
  resolveScene: (p) => JSON.parse(readFileSync(resolve(sceneDir, p), 'utf-8')),
106
113
  });
@@ -13,6 +13,7 @@
13
13
  * loads incanto-check the scene is legal and every asset resolves
14
14
  * plays incanto-playtest a seeded run can actually finish it
15
15
  * draws incanto-frame the GPU drew something, and the subject is in shot
16
+ * says incanto-logs what the running game is complaining about
16
17
  *
17
18
  * Exit 1 when a rung FAILED. An unmeasured rung is not a failure — it prints
18
19
  * what to arrange, and says so in the summary.
@@ -41,6 +42,7 @@ Walks the whole ladder and tells you the ONE thing to do next:
41
42
  loads the scene is legal and every asset resolves (incanto-check)
42
43
  plays a seeded run can actually finish it (incanto-playtest)
43
44
  draws the GPU drew something and the subject is in shot (incanto-frame)
45
+ says the running game logs no errors and lost no assets (incanto-logs)
44
46
 
45
47
  Without a scene it looks for one *.scene.json under the current directory.
46
48
  "draws" needs a dev server with the page open; without one it is reported as
@@ -48,10 +50,18 @@ NOT MEASURED rather than failed — a missing measurement is not a broken game.`
48
50
  process.exit(0);
49
51
  }
50
52
 
51
- /** The scene to verify: the one named, or the only one that can be found. */
53
+ /**
54
+ * The scene to verify: the one named, or the only one that can be found.
55
+ *
56
+ * ALWAYS the same shape. It used to return a bare string for a named scene and
57
+ * an object for a search, and the caller destructures — so `{ scene } = "a.json"`
58
+ * was `undefined` and naming your scene was the one way to make the whole
59
+ * ladder do nothing. It only ever worked when you named nothing, which is how
60
+ * it was run while being written.
61
+ */
52
62
  function findScene() {
53
63
  const named = argv.find((a) => !a.startsWith('-') && a.endsWith('.json'));
54
- if (named) return named;
64
+ if (named) return { scene: named, candidates: [named] };
55
65
  const found = [];
56
66
  const walk = (dir, depth) => {
57
67
  if (depth > 4 || found.length > 8) return;
@@ -190,6 +200,35 @@ if (rungs[0].status === 'pass') {
190
200
  * deprecation notice lands on stdout ahead of the report, and treating that as
191
201
  * "the tool could not run" is how this reported a healthy scene as broken.
192
202
  */
203
+ // ---- says ---------------------------------------------------------------
204
+ {
205
+ const r = run('logs', ['--json']);
206
+ const report = safeJson(r.stdout);
207
+ if (!report) {
208
+ rungs.push({
209
+ name: 'says',
210
+ status: 'unmeasured',
211
+ summary: firstLine(r.stderr) ?? 'no page to ask',
212
+ fix: 'open the preview page and run this again — the log buffer is in the browser',
213
+ });
214
+ } else if (report.ok) {
215
+ rungs.push({ name: 'says', status: 'pass', summary: 'no errors, no failed assets' });
216
+ } else {
217
+ const asset = report.assetErrors?.[0];
218
+ rungs.push({
219
+ name: 'says',
220
+ status: 'fail',
221
+ // A game whose grass and water are fine still fails here when its player
222
+ // never loaded — which is the case this rung exists for, and the one
223
+ // `draws` calls healthy.
224
+ summary: asset
225
+ ? `asset ${asset.ref} never loaded: ${asset.error}`
226
+ : (report.worst?.message ?? `${report.stats?.errors ?? 0} errors were swallowed`),
227
+ fix: 'read it in full: `incanto-logs`',
228
+ });
229
+ }
230
+ }
231
+
193
232
  function safeJson(text) {
194
233
  const raw = text ?? '';
195
234
  const start = raw.indexOf('{');
package/dist/2d.d.ts CHANGED
@@ -2,6 +2,7 @@ import { Et as Node, P as Scene$1, S as Scheduler, T as RendererStats, b as Engi
2
2
  import { n as DiagnosticSink, t as EditorSwitchOptions } from "./editor-switch-DAvWQeld.js";
3
3
  import { i as SceneJson, s as JsonObject } from "./schema-CFeioQRE.js";
4
4
  import { t as LoadSceneOptions } from "./loader-CeyU_bm1.js";
5
+ import { n as AnimationEntry } from "./sprite-animation-CMr6f1K2.js";
5
6
  import { t as ParticleSim } from "./particle-sim-BzJ1yxoE.js";
6
7
  import { Group, Mesh, Object3D, Scene, Texture } from "three";
7
8
  import * as RapierNs from "@dimforge/rapier2d-compat";
@@ -135,10 +136,16 @@ declare class RigidBody2D extends PhysicsBody2D {
135
136
  declare class CharacterBody2D extends PhysicsBody2D {
136
137
  static override readonly typeName: string;
137
138
  static override readonly props: PropSchema;
139
+ /**
140
+ * Renamed to match the 3D body, where `snapToGround` had to give the name
141
+ * back to `Node3D`'s PLACEMENT prop. 2D has no placement prop, so nothing is
142
+ * ambiguous here and old scenes keep loading unchanged.
143
+ */
144
+ static readonly propAliases: Record<string, string>;
138
145
  /** px/s, y-down (up = -y). */
139
146
  velocity: number[];
140
147
  /** Keep contact on slopes/steps while not moving upward. */
141
- snapToGround: boolean;
148
+ stickToGround: boolean;
142
149
  /** Max climbable slope angle. */
143
150
  slopeLimitDeg: number;
144
151
  /** @internal Updated by Physics2D.moveAndSlide. */
@@ -373,6 +380,11 @@ interface SpriteFromLibraryResult {
373
380
  * Turn a library sprite-animation JSON into a scene-ready spritesheet asset
374
381
  * declaration + AnimatedSprite2D props. `autoplay` defaults to the first
375
382
  * LOOPING animation (idle-like), falling back to the first one.
383
+ *
384
+ * The animation map also gets ALIASES for the movement states a character
385
+ * controller emits but the sheet does not name (`run` → `move`, `fall` →
386
+ * `idle`), so the converted props and the documented
387
+ * `movementStateChanged → play` wiring work together out of the box.
376
388
  */
377
389
  declare function spriteFromLibraryMeta(meta: unknown, opts: {
378
390
  url: string;
@@ -411,15 +423,6 @@ declare class Sprite2D extends Node2D {
411
423
  }
412
424
  //#endregion
413
425
  //#region src/2d/nodes/animated-sprite-2d.d.ts
414
- interface AnimationDef {
415
- /**
416
- * Two numbers = inclusive [start, end] frame RANGE.
417
- * Any other length = explicit frame list.
418
- */
419
- frames: number[];
420
- fps: number;
421
- loop?: boolean;
422
- }
423
426
  /**
424
427
  * Spritesheet animation: pure-JSON animation map, frame selection via a UV
425
428
  * window on the node's own texture clone. Frames advance in `update(dt)` —
@@ -431,7 +434,7 @@ declare class AnimatedSprite2D extends Sprite2D {
431
434
  static override readonly props: PropSchema;
432
435
  /** `'$assetKey'` of a spritesheet asset. */
433
436
  sheet: string;
434
- animations: Record<string, AnimationDef>;
437
+ animations: Record<string, AnimationEntry>;
435
438
  autoplay: string;
436
439
  playing: boolean;
437
440
  /** Absolute frame index within the sheet. */
@@ -440,8 +443,14 @@ declare class AnimatedSprite2D extends Sprite2D {
440
443
  private frameList;
441
444
  private frameIndex;
442
445
  private frameTime;
446
+ private currentDef;
443
447
  private ownTexture;
444
448
  private loadedSheetRef;
449
+ /**
450
+ * `currentAnimation` reports the clip that is RUNNING, not the name asked
451
+ * for: an alias is a rename, and a state machine that reads back `fall` while
452
+ * the idle frames play is describing something that is not happening.
453
+ */
445
454
  play(name: string): void;
446
455
  stop(): void;
447
456
  override onReady(): void;
@@ -920,4 +929,4 @@ declare function parseCells(cells: readonly (string | readonly number[])[], lege
920
929
  */
921
930
  declare function mergeSolidRects(grid: readonly (readonly number[])[], solid: Set<number>): TileRect[];
922
931
  //#endregion
923
- export { AnimatedSprite2D, type AnimationDef, Area2D, type AssetStatus, AssetStore2D, Camera2D, CharacterBody2D, CharacterController2D, ColorRect2D, type CreateGame2DOptions, type Game2D, Joint2D, type JointType2D, Label, Node2D, Particles2D, Physics2D, type Physics2DOptions, PhysicsBody2D, Renderer2D, type Renderer2DOptions, type ResolvedSpriteTexture, RigidBody2D, type SheetInfo, Sprite2D, type SpriteFromLibraryResult, StaticBody2D, type Sync2DResult, type TextureLoadCallbacks, TileMap2D, type TileRect, type UIAnchor, UILayer, createGame2D, enablePhysics2D, mergeSolidRects, parseCells, registerNodes2D, spriteFromLibraryMeta, syncTree2D };
932
+ export { AnimatedSprite2D, Area2D, type AssetStatus, AssetStore2D, Camera2D, CharacterBody2D, CharacterController2D, ColorRect2D, type CreateGame2DOptions, type Game2D, Joint2D, type JointType2D, Label, Node2D, Particles2D, Physics2D, type Physics2DOptions, PhysicsBody2D, Renderer2D, type Renderer2DOptions, type ResolvedSpriteTexture, RigidBody2D, type SheetInfo, Sprite2D, type SpriteFromLibraryResult, StaticBody2D, type Sync2DResult, type TextureLoadCallbacks, TileMap2D, type TileRect, type UIAnchor, UILayer, createGame2D, enablePhysics2D, mergeSolidRects, parseCells, registerNodes2D, spriteFromLibraryMeta, syncTree2D };
package/dist/2d.js CHANGED
@@ -1,12 +1,65 @@
1
1
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
2
- import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-CniOiWzN.js";
3
- import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-R2JTnIMw.js";
4
- import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-BmgXBNDB.js";
2
+ import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-BgV6UbVA.js";
3
+ import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-BNPZYJmd.js";
4
+ import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-vyCBfACH.js";
5
5
  //#region src/2d/library-sprite.ts
6
6
  /**
7
+ * What a `CharacterController2D`/`3D` will ask a skin to play, and the clip in
8
+ * a library sheet that answers for it.
9
+ *
10
+ * The two vocabularies never met: the controller emits `run`, every library
11
+ * sheet calls it `move`, and no sheet anywhere ships a `fall` — so the wiring
12
+ * the 2D skill documents in one line (`movementStateChanged → play`) asked for
13
+ * animations that do not exist, on artwork from the engine's own catalog.
14
+ *
15
+ * These are ALIASES, so they read as what they are, and the author can point
16
+ * any of them somewhere better.
17
+ */
18
+ const CONTROLLER_STATES = [
19
+ ["run", [
20
+ "run",
21
+ "move",
22
+ "walk"
23
+ ]],
24
+ ["jump", ["jump", "idle"]],
25
+ ["fall", [
26
+ "fall",
27
+ "jump",
28
+ "idle"
29
+ ]],
30
+ ["wallSlide", ["wallSlide", "idle"]],
31
+ ["dash", [
32
+ "dash",
33
+ "run",
34
+ "move",
35
+ "idle"
36
+ ]]
37
+ ];
38
+ /**
39
+ * Fill in the states the sheet does not name, and touch nothing it does.
40
+ *
41
+ * Silent when there is no `idle` to fall back on: a spinning-coin sheet is not
42
+ * a character, and inventing movement states for it would put clips in the map
43
+ * that mean nothing.
44
+ */
45
+ function addControllerStates(animations) {
46
+ const real = new Set(Object.keys(animations));
47
+ if (!real.has("idle")) return;
48
+ for (const [state, candidates] of CONTROLLER_STATES) {
49
+ if (real.has(state)) continue;
50
+ const target = candidates.find((c) => c !== state && real.has(c));
51
+ if (target) animations[state] = target;
52
+ }
53
+ }
54
+ /**
7
55
  * Turn a library sprite-animation JSON into a scene-ready spritesheet asset
8
56
  * declaration + AnimatedSprite2D props. `autoplay` defaults to the first
9
57
  * LOOPING animation (idle-like), falling back to the first one.
58
+ *
59
+ * The animation map also gets ALIASES for the movement states a character
60
+ * controller emits but the sheet does not name (`run` → `move`, `fall` →
61
+ * `idle`), so the converted props and the documented
62
+ * `movementStateChanged → play` wiring work together out of the box.
10
63
  */
11
64
  function spriteFromLibraryMeta(meta, opts) {
12
65
  const m = meta;
@@ -25,6 +78,7 @@ function spriteFromLibraryMeta(meta, opts) {
25
78
  firstName ??= name;
26
79
  if (loop && firstLooping === null) firstLooping = name;
27
80
  }
81
+ addControllerStates(animations);
28
82
  const autoplay = opts.autoplay ?? firstLooping ?? firstName;
29
83
  return {
30
84
  asset: {
package/dist/3d.d.ts CHANGED
@@ -370,9 +370,27 @@ declare class CharacterBody3D extends PhysicsBody3D {
370
370
  static override readonly props: PropSchema;
371
371
  /** m/s, y-up (up = +y). */
372
372
  velocity: number[];
373
- snapToGround: boolean;
373
+ /**
374
+ * Hold the capsule against the surface while it moves, so walking down a
375
+ * slope does not become a series of small falls.
376
+ *
377
+ * Named apart from `Node3D.snapToGround` (which PLACES a node on the ground
378
+ * at load) because sharing the name silently broke both: the boolean shadowed
379
+ * the placement prop, its `true` default made the placement pass teleport
380
+ * every character body in a terrain scene, and a lift value was rejected
381
+ * outright — so the only escape from the teleport also turned this off.
382
+ */
383
+ stickToGround: boolean;
374
384
  /** Max climbable slope angle. */
375
385
  slopeLimitDeg: number;
386
+ /**
387
+ * `snapToGround` on a character body is ambiguous, so it is refused.
388
+ *
389
+ * A boolean here used to mean the ground-stick, and as placement it means
390
+ * "leave y alone" — which is already the default. Rather than pick one and
391
+ * silently do the other thing to an existing scene, say which prop was meant.
392
+ */
393
+ override onReady(): void;
376
394
  /** @internal Updated by Physics3D.moveAndSlide. */
377
395
  _grounded: boolean;
378
396
  moveAndSlide(): void;
@@ -1006,6 +1024,25 @@ interface ModelVerdict {
1006
1024
  /** Clip names baked into the file itself (playable by name, no asset entry). */
1007
1025
  embeddedClips: string[];
1008
1026
  sceneJson: SceneJson | null;
1027
+ /**
1028
+ * The whole playable character — body, controller, skin, clips and input —
1029
+ * for a rig that can take the library's locomotion clips. Null for a prop.
1030
+ *
1031
+ * The model on its own is a statue, and every step from there is documented
1032
+ * somewhere different: the controller must sit under a dynamic body, the skin
1033
+ * must be sized to the capsule and dropped to its feet, the clips must be
1034
+ * declared, and the input actions must exist or nothing reads the keyboard.
1035
+ */
1036
+ characterJson: CharacterJson | null;
1037
+ }
1038
+ interface CharacterJson {
1039
+ assets: Record<string, {
1040
+ type: string;
1041
+ url: string;
1042
+ }>;
1043
+ /** The actions `CharacterController3D` reads — without them it never moves. */
1044
+ input: Record<string, unknown>;
1045
+ node: Record<string, unknown>;
1009
1046
  }
1010
1047
  interface VerdictTarget {
1011
1048
  url: string;