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
package/editor/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Incanto Scene Editor</title>
7
7
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%236ee7dc'/><text x='8' y='12' text-anchor='middle' font-size='11' font-family='monospace' fill='%230e1018'>i</text></svg>" />
8
- <script type="module" crossorigin src="./assets/index-B-6eYZEi.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-5dEIhvsf.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incanto",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -99,6 +99,7 @@
99
99
  "incanto-feel": "bin/incanto-feel.mjs",
100
100
  "incanto-new": "bin/incanto-new.mjs",
101
101
  "incanto-frame": "bin/incanto-frame.mjs",
102
- "incanto-verify": "./bin/incanto-verify.mjs"
102
+ "incanto-verify": "./bin/incanto-verify.mjs",
103
+ "incanto-logs": "./bin/incanto-logs.mjs"
103
104
  }
104
105
  }
@@ -1696,7 +1696,7 @@
1696
1696
  "maxItems": 2,
1697
1697
  "default": [0, 0]
1698
1698
  },
1699
- "snapToGround": {
1699
+ "stickToGround": {
1700
1700
  "type": "boolean",
1701
1701
  "default": true
1702
1702
  },
@@ -1801,8 +1801,7 @@
1801
1801
  "default": "default"
1802
1802
  },
1803
1803
  "snapToGround": {
1804
- "type": "boolean",
1805
- "default": true
1804
+ "default": null
1806
1805
  },
1807
1806
  "collider": {
1808
1807
  "type": "object",
@@ -1817,6 +1816,10 @@
1817
1816
  "maxItems": 3,
1818
1817
  "default": [0, 0, 0]
1819
1818
  },
1819
+ "stickToGround": {
1820
+ "type": "boolean",
1821
+ "default": true
1822
+ },
1820
1823
  "slopeLimitDeg": {
1821
1824
  "type": "number",
1822
1825
  "default": 45
@@ -7443,6 +7446,10 @@
7443
7446
  "type": "string",
7444
7447
  "default": ""
7445
7448
  },
7449
+ "format": {
7450
+ "type": "string",
7451
+ "default": ""
7452
+ },
7446
7453
  "size": {
7447
7454
  "type": "number",
7448
7455
  "default": 16
@@ -171,6 +171,15 @@ Map movement states straight in JSON — no behavior needed:
171
171
  The controller writes `skin.animation` on every state change (crossfaded by
172
172
  the model). The `movementStateChanged` signal still fires for extras.
173
173
 
174
+ **Where those clips come from.** Locomotion clips live at
175
+ `https://agent8-games.verse8.io/assets/3d/animations/mixamorig/<name>.glb`
176
+ (`idle-00`, `walk`, `run-medium`, `run-fast`, `jump`) and play on any mixamorig
177
+ model as they are — retargeted through the humanoid map for a VRM. You do not
178
+ have to assemble any of this by hand: run the character URL your asset MCP gave
179
+ you through `bunx incanto-model <url>` and it prints the whole thing — assets,
180
+ input actions, body, controller and skin — ready to paste. See
181
+ `incanto-3d-models.md`.
182
+
174
183
  The controller also yaw-rotates the sibling at `skinPath` ('../Skin') toward
175
184
  the move direction at `turnSpeed` rad/s (100 = instant snap) — the body
176
185
  itself never rotates. The skin MOUNTS at 180° (facing away from the default
@@ -222,8 +231,10 @@ NPCs feel alive when they track you. One node, zero code:
222
231
 
223
232
  ## Facing a direction in 3D — the +Z-FORWARD rule (read before turning ANY skin)
224
233
 
225
- This trips people up REPEATEDLY, so here is the one rule. agent8's `base-model` (and
226
- any model you give `skinYawOffset: 0`) is **+Z-FORWARD**: its face looks down +Z at
234
+ This trips people up REPEATEDLY, so here is the one rule. agent8's `base-model`
235
+ (`https://agent8-games.verse8.io/assets/3d/characters/realistic%20style/base-model.glb`
236
+ — note the escaped space; it is the reference rig these docs measure against) and
237
+ any model you give `skinYawOffset: 0` are **+Z-FORWARD**: its face looks down +Z at
227
238
  rotation 0. To turn it to face a world heading `(dx, dz)`:
228
239
 
229
240
  ```ts
@@ -8,6 +8,46 @@ description: Loading GLB/glTF and VRM models in Incanto — the ModelInstance3D
8
8
  > Shipped inside the `incanto` npm package — this document always matches the
9
9
  > installed engine version. Sibling skills live in `node_modules/incanto/skills/`.
10
10
 
11
+ ## Where models come from
12
+
13
+ **Search, don't invent.** Model URLs come from your asset MCP (vector search
14
+ over the agent8 library) or from the editor's 📚 button
15
+ (`bunx incanto-editor --token <v8 token>`) — never from a guessed path. There is
16
+ no 3D model in the package's own catalog: `incanto-assets` ships 2D sprites,
17
+ tiles, items, foliage/terrain textures and audio, and characters are the one
18
+ thing you are expected to go and get.
19
+
20
+ Then run the URL through the inspector, which is where a found file becomes a
21
+ scene:
22
+
23
+ ```bash
24
+ bunx incanto-model "https://…/base-model.glb"
25
+ # mixamorig rig — the 3d/animations clips play on this as they are
26
+ # stands 1.5 units tall
27
+ #
28
+ # paste a playable character into your scene:
29
+ # { "assets": {…model + idle/walk/run/fastRun/airborne clips…},
30
+ # "input": {…move / jump / sprint…},
31
+ # "node": { "type": "RigidBody3D", … Controller + Skin … } }
32
+ ```
33
+
34
+ For a **mixamorig or VRM humanoid** that is the whole character — body,
35
+ controller, skin fitted to the capsule and dropped to its feet, the locomotion
36
+ clips declared, and the input actions without which nothing reads the keyboard.
37
+ Paste it and the character walks. For a prop, you get the model node.
38
+
39
+ ### The locomotion clips
40
+
41
+ `https://agent8-games.verse8.io/assets/3d/animations/mixamorig/<name>.glb` —
42
+ `idle-00` · `walk` · `run-medium` · `run-fast` · `jump`, plus
43
+ `melee-attack`, `punch-00/01`, `kick-00/01/02`, `cast`, `swim`,
44
+ `hit-to-body`, `death-backward`, `dance-wave`, and the `rifle-`/`pistol-`
45
+ sets.
46
+
47
+ These are not art you go searching for — they are the rig's vocabulary. They
48
+ play on a mixamorig model as they are, and retarget through the humanoid map
49
+ onto a VRM, so they fit whatever character your asset search returned.
50
+
11
51
  ## ALWAYS inspect before you place
12
52
 
13
53
  A file name tells you nothing about a model's size or contents. The package ships an
@@ -11,6 +11,13 @@ description: Where game art comes from in Incanto — the FULL built-in catalog
11
11
  Resolve art in this order: ① built-ins (zero setup) → ② asset MCP servers /
12
12
  known URLs → ③ art-free primitives. Never invent asset URLs.
13
13
 
14
+ **3D characters and props are step ②, always** — the built-in catalog is 2D
15
+ sprites, tiles, items, foliage/terrain textures and audio, with no models in it.
16
+ Search with your asset MCP (or the editor's 📚 library), then run the URL
17
+ through `bunx incanto-model <url>`: for a rigged humanoid it prints a whole
18
+ playable character — model, locomotion clips, body, controller, skin and input
19
+ — ready to paste. See `incanto-3d-models.md`.
20
+
14
21
  ## 1. Built-in assets (in the package)
15
22
 
16
23
  ```bash
@@ -102,6 +109,14 @@ const { asset, props } = spriteFromLibraryMeta(animJson, {
102
109
  // asset → scene assets.hero; props → an AnimatedSprite2D's props. Done.
103
110
  ```
104
111
 
112
+ The `animations` map it returns (and the one `incanto-assets copy` prints) also
113
+ carries ALIASES for the movement states a character controller emits and the
114
+ sheet does not name — `"run": "move"`, `"fall": "idle"` — because library sheets
115
+ are `idle`/`move`/`attack` and no sheet ships a falling pose. That is what makes
116
+ the printed JSON work when you paste it next to
117
+ `movementStateChanged → play`; repoint any alias once you have the art. Sheets
118
+ with no `idle` (a spinning coin) get none: they are not characters.
119
+
105
120
  For 3D files, ALWAYS inspect before placing: `bunx incanto-model file.glb`
106
121
  (bounds, animations, rig — see incanto-3d-models).
107
122
 
@@ -288,11 +288,32 @@ bunx incanto-check # will it load? is anything lit?
288
288
  printf 'step 500\nframing\nquit\n' | bunx incanto-play src/game.scene.json
289
289
  ```
290
290
 
291
- `framing` tells you what the camera can actually SEE (on screen / off screen /
292
- behind it), what lights the scene, and which colliders intersect. Then read
293
- `stats().errors` (something threw and got skipped) and `assetErrors()` (a model
294
- 404'd). All four are silent failures otherwise the screen just looks wrong,
295
- or empty, and nothing throws.
291
+ `framing` tells you what the camera can actually SEE, in 2D's own terms: the
292
+ `viewport.design` window centred on the current `Camera2D` **and clamped by its
293
+ `limits`**, which is the view the renderer really draws — a following camera
294
+ sits at the player's y, and in a 544 px world that is hundreds of pixels past
295
+ anything a 540 px window can show.
296
+
297
+ ```
298
+ camera /Game/Camera centred [480, 274] showing 960×540px
299
+ 11 in view, 2 outside it
300
+ onScreen /Game/Level (TileMap2D) [0, 0] screen [0.333, -0.007] 160px
301
+ onScreen /Game/Gems/Gem1/Icon (Sprite2D) [512, 428] screen [0.067, 0.57] 157px
302
+ offscreen /Game/Goblins/Goblin2/Skin (AnimatedSprite2D) [1002, 512] … 574px
303
+ overlap /Game/Gems/Gem4 ∩ /Game/Level
304
+ ```
305
+
306
+ `screen` is −1…1 across the window, so `1.089` means "just past the right
307
+ edge". A node counts as in view when its BOX is — a `ColorRect2D`'s `size`, a
308
+ `TileMap2D`'s whole grid (which hangs from its TOP-LEFT, not its centre), or a
309
+ collider. A bare `Sprite2D` declares no size, so read `offscreen` there as "its
310
+ ORIGIN is outside the view". Nothing is ever "behind" a 2D camera and nothing is
311
+ lit, so neither is reported; `overlap` is real interpenetration, not resting on
312
+ a platform. HUD widgets are screen-space and are left out entirely.
313
+
314
+ Then read `stats().errors` (something threw and got skipped) and `assetErrors()`
315
+ (a texture 404'd). All of these are silent failures otherwise — the screen just
316
+ looks wrong, or empty, and nothing throws.
296
317
 
297
318
  ## Platformer game feel (CharacterController2D)
298
319
 
@@ -324,6 +345,37 @@ behaves exactly as before.
324
345
  character from scene JSON instead of a behavior polling velocities. The 3D
325
346
  sibling has had this signal since it shipped.
326
347
 
348
+ ```json
349
+ { "signal": "movementStateChanged", "from": "Player/Ctl",
350
+ "to": "Player/Skin", "handler": "play" }
351
+ ```
352
+
353
+ **The sheet will not have all six clips — alias the rest.** Library sheets
354
+ (including every packaged character) carry `idle`/`move`/`attack`, and none of
355
+ them ships a falling pose. An `animations` entry may be the NAME of another
356
+ entry, which is what the states you have no artwork for should be:
357
+
358
+ ```json
359
+ "animations": {
360
+ "idle": { "frames": [0, 5], "fps": 10, "loop": true },
361
+ "move": { "frames": [6, 11], "fps": 10, "loop": true },
362
+ "attack": { "frames": [12, 17], "fps": 10 },
363
+ "run": "move", "jump": "idle", "fall": "idle", "dash": "move"
364
+ }
365
+ ```
366
+
367
+ `incanto-assets copy` and `spriteFromLibraryMeta` already print these aliases,
368
+ so pasting their output next to the wiring above works as-is; point any of them
369
+ somewhere better when you have the art. Aliases may chain (`fall` → `jump` →
370
+ `idle`), a circle is a hard error, and a name that resolves to nothing fails at
371
+ READY — not on the frame the character first jumps. A misspelt clip is still a
372
+ hard `UNKNOWN_ANIMATION`: an alias is a rename you wrote, not a silent fallback.
373
+
374
+ **A cosmetic clip cannot stop the character.** If a `play` wire throws anyway,
375
+ the engine turns THAT WIRE off and reports it — the controller keeps running.
376
+ Read `stats().errors`, because the game will look fine and be missing an
377
+ animation.
378
+
327
379
  Wall moves need to know there IS a wall: `CharacterBody2D` now answers
328
380
  `isOnWall()`, `wallSide()` (-1 left / +1 right) and `isOnCeiling()` as well as
329
381
  `isOnFloor()`. Before, the floor was the only surface a character could report,
@@ -401,6 +401,13 @@ the same bug.
401
401
  cloud). The grounding check below skips these, so mark them.
402
402
  - omitted — `y` is used exactly as authored.
403
403
 
404
+ **On a `CharacterBody2D`/`3D` the ground-STICK is `stickToGround`** (default
405
+ true — hold the capsule against the surface while it moves). It used to be
406
+ called `snapToGround`, which shadowed the placement prop above: a character
407
+ body was silently teleported onto the terrain at load, a lift value was
408
+ rejected, and the only escape also turned the ground-stick off. A boolean
409
+ `snapToGround` on a character body is now a hard error naming both props.
410
+
404
411
  Resolved at load, in the same frame the terrain is built, and it honours an
405
412
  ancestor's yaw, so props under a turned rig land where they look.
406
413
 
@@ -90,9 +90,19 @@ dies once.
90
90
  | `invulnerableFor` | `0` | seconds of immunity after a hit (i-frames; 0 = off) |
91
91
  | `freeOnDeath` | `false` | `queueFree()` this node when it dies (clone-safe) |
92
92
 
93
- Signals: `damaged(amount, current)` · `healed(amount, current)` · `died`
93
+ Signals: `damaged(amount, current)` · `healed(amount, current)` ·
94
+ `healthChanged(current, max)` · `died`
94
95
  Methods: `damage(n)` · `heal(n)` · `kill()` — state: `current`, `isDead`
95
96
 
97
+ **`healthChanged` is the one to put on a health BAR.** `damaged`/`healed` lead
98
+ with the DELTA, so wiring either to `UiBar.setValue` paints the damage as the
99
+ health and looks like it works; `healthChanged` leads with the current value and
100
+ carries the max, so `{ "signal": "healthChanged", "from": "Player", "to":
101
+ "HUD/Hp", "handler": "setValue" }` is the whole health bar. It also fires for
102
+ regeneration, which the other two never do — a regen bar wired to `healed` sits
103
+ still while the character heals. Use `damaged`/`healed` for reacting to the HIT
104
+ (sfx, flash, knockback), where the amount is the point.
105
+
96
106
  ```json
97
107
  { "name": "Player", "type": "CharacterBody2D", "groups": ["player"],
98
108
  "props": { "collider": { "shape": "capsule", "radius": 12, "height": 32 } },
@@ -162,6 +172,13 @@ arg 0) or `→ Collector.collect`.
162
172
 
163
173
  Signals: `collected(value, other)`
164
174
 
175
+ The collector is resolved from the contacted node, its descendants, then its
176
+ ancestor chain — the same search `DamageOnContact` uses for `Health`. So a
177
+ player with a wider `Grab` sensor child still collects, and `other` is the
178
+ ENTITY that owns the group, not whichever part of it touched first. (It stops
179
+ at the entity: past a shared parent a crate standing next to the player would
180
+ otherwise collect the coin.)
181
+
165
182
  ```json
166
183
  { "name": "Coin", "type": "Area2D",
167
184
  "props": { "collider": { "shape": "circle", "radius": 8 } },
@@ -27,6 +27,45 @@ headless tests (core nodes, no three.js). Widgets pick one of 9 anchors:
27
27
  `topLeft top topRight left center right bottomLeft bottom bottomRight`.
28
28
  Widgets stacked on the same anchor form a column.
29
29
 
30
+ ## Driving widgets from the scene JSON (no TypeScript)
31
+
32
+ The widgets take the gameplay signals directly, so a score line and a health bar
33
+ are `connections[]` entries — not a behavior whose whole job is one assignment:
34
+
35
+ ```json
36
+ "connections": [
37
+ { "signal": "scoreChanged", "from": ".", "to": "HUD/Score", "handler": "setText" },
38
+ { "signal": "healthChanged", "from": "Player", "to": "HUD/Hp", "handler": "setValue" },
39
+ { "signal": "lifeLost", "from": ".", "to": "HUD/Lives", "handler": "setText" }
40
+ ]
41
+ ```
42
+
43
+ ```json
44
+ { "name": "Score", "type": "UiText",
45
+ "props": { "anchor": "topRight", "text": "Gems 0 / 8", "format": "Gems {} / 8" } }
46
+ ```
47
+
48
+ - **`UiText.setText(value)`** — writes the whole line, or fills the `{}` slot in
49
+ `format` when you set one. `format` is resolved at PAINT like `text`, so
50
+ `"@t:hud.gems"` works and switching locale re-reads it. Until the first value
51
+ arrives it shows `text`, which is why you write both (`"Gems 0 / 8"` is the
52
+ opening line, `"Gems {} / 8"` is the template).
53
+ - **`UiBar.setValue(current, max?)`** — takes the max as a second argument
54
+ because that is the shape `healthChanged` already has, so a raised ceiling
55
+ needs no second wire. `UiBar.setMax(max)` moves it alone.
56
+ - **`UiBanner.show(text)`** — already a method, already wireable.
57
+
58
+ **Wire `healthChanged`, not `damaged`.** `damaged(amount, current)` and
59
+ `healed(amount, current)` lead with the DELTA — wire either to `setValue` and
60
+ the bar paints the damage as the health and looks like it works. `setValue`
61
+ refuses a non-number and names the right signal in the message, but a plausible
62
+ wrong NUMBER is the trap worth knowing. `healthChanged(current, max)` also fires
63
+ for regeneration, which the other two never do.
64
+
65
+ Which signal carries what, at a glance: `ScoreKeeper.scoreChanged(score)` ·
66
+ `ScoreKeeper.lifeLost(lives)` · `Collector.totalChanged(total)` ·
67
+ `Health.healthChanged(current, max)` — all value-first, all wireable as-is.
68
+
30
69
  ## Driving widgets from behaviors
31
70
 
32
71
  Plain node access — same as everything else:
@@ -214,7 +214,7 @@ Signals: `finished`
214
214
  | `visible` | `true` | boolean |
215
215
  | `collider` | `{}` | object |
216
216
  | `velocity` | `[0,0]` | array |
217
- | `snapToGround` | `true` | boolean |
217
+ | `stickToGround` | `true` | boolean |
218
218
  | `slopeLimitDeg` | `45` | number |
219
219
 
220
220
  Signals: `triggerEnter(other)` · `triggerExit(other)`
@@ -230,9 +230,10 @@ Signals: `triggerEnter(other)` · `triggerExit(other)`
230
230
  | `visible` | `true` | boolean |
231
231
  | `renderOrder` | `0` | number |
232
232
  | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
233
- | `snapToGround` | `true` | boolean |
233
+ | `snapToGround` | `null` | null |
234
234
  | `collider` | `{}` | object |
235
235
  | `velocity` | `[0,0,0]` | array |
236
+ | `stickToGround` | `true` | boolean |
236
237
  | `slopeLimitDeg` | `45` | number |
237
238
 
238
239
  Signals: `triggerEnter(other)` · `triggerExit(other)`
@@ -1133,6 +1134,7 @@ Signals: `dragStarted` · `dragCancelled` · `droppedOn` · `dropped` · `change
1133
1134
  | `draggable` | `false` | boolean |
1134
1135
  | `dropTarget` | `false` | boolean |
1135
1136
  | `text` | `""` | string |
1137
+ | `format` | `""` | string |
1136
1138
  | `size` | `16` | number |
1137
1139
  | `color` | `"#ffffff"` | string |
1138
1140
  | `shadow` | `true` | boolean |
@@ -1340,7 +1342,7 @@ Signals: `flowChanged`
1340
1342
  | `invulnerableFor` | `0` | number |
1341
1343
  | `freeOnDeath` | `false` | boolean |
1342
1344
 
1343
- Signals: `damaged(amount, current)` · `healed(amount, current)` · `died`
1345
+ Signals: `damaged(amount, current)` · `healed(amount, current)` · `died` · `healthChanged`
1344
1346
 
1345
1347
  ### `Interactable`
1346
1348
 
@@ -76,7 +76,7 @@ mesh or one model and warns when the body carries more.
76
76
  Areas overlapping OTHER Areas fire too (e.g. a weapon-hitbox Area over an
77
77
  enemy-hitbox Area) — neither side needs to be a Body.
78
78
  - **`CharacterBody2D/3D`** — kinematic character (Rapier KCC). Props: `collider`
79
- (capsule recommended), `velocity`, `snapToGround true`, `slopeLimitDeg 45`.
79
+ (capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45`.
80
80
  API: `moveAndSlide()` (call from `fixedUpdate`), `isOnFloor()`.
81
81
 
82
82
  Physics simulates WORLD positions: bodies under offset parents work (offsets compose),
@@ -48,16 +48,23 @@ two.
48
48
  |---|---|
49
49
  | `reached "won"` | `GameFlow` state `'won'`, or a `won` signal (e.g. `ScoreKeeper`) |
50
50
  | `lost` | `GameFlow` `'gameover'`, a `lost` signal, or the player's `Health.died` |
51
- | `fell` | the player's world Y dropped below `--fall-below` (default: 50 under the spawn) |
51
+ | `fell` | the player left the world past `--fall-below` (default: 50 m under the spawn in 3D, 1000 px under it in 2D) |
52
52
  | `error` | `stats().errors` went above zero — a behavior threw |
53
53
  | `stuck` | none of the above before the clock ran out |
54
- | `never reached` | destinations the bot never came within 2 units of |
54
+ | `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) |
55
55
  | `never fired` | signals a `connections[]` entry listens to that never happened |
56
56
  | `danger` | how many times the player's `Health` emitted `damaged` |
57
57
 
58
58
  **Nothing here is a guess about your design.** The scene already declares what
59
59
  winning is; this only listens.
60
60
 
61
+ **2D and 3D are read in their own units.** A 2D scene is pixels with **+y
62
+ DOWN**, so "left the world" means y grew past the line and a jump is not a fall;
63
+ a 3D scene is metres with +y up. Both defaults scale to the character —
64
+ 32 px next to a ~34 px sprite is the same reach as 2 m next to a 1.8 m rig.
65
+ Set `--fall-below` / `--reach-radius` yourself only when your level says
66
+ otherwise (a very tall 2D shaft, a tiny 3D prop).
67
+
61
68
  ## Destinations are what YOU marked
62
69
 
63
70
  A node counts as somewhere to go when it **runs a behavior**, **carries a
@@ -252,6 +252,13 @@ Also `incanto-play`'s `framing` command, and `describeFraming(scene)` /
252
252
  as on screen when its `size` box (or collider) is, not only its origin — so a
253
253
  200 m island whose origin sits off to the left is correctly `onScreen`.
254
254
 
255
+ **A 2D scene gets a 2D report**, in pixels: the `viewport.design` window centred
256
+ on the current `Camera2D` and CLAMPED by its `limits`, which is the view the
257
+ renderer actually draws. Nothing is ever `behind` a 2D camera and nothing is
258
+ lit, so those two lines are absent rather than answered — read
259
+ `report.dimension` if you consume the structured form. A 2D scene with no
260
+ camera is not a failure: the documented view is `(0,0)`–`(design)`.
261
+
255
262
  **For actual pixels**, turn on `preserveDrawingBuffer` — WebGL clears the buffer
256
263
  the instant a frame composites, so `canvas.toDataURL()` and every screenshot
257
264
  built on it come back BLANK without it:
@@ -278,6 +285,13 @@ stopped. It no longer can.
278
285
  behavior attached to a `CharacterController3D` stops that behavior — the
279
286
  character still walks and the camera still turns. Only a throw from the node's
280
287
  own update quarantines the node.
288
+ - **A `connections[]` handler that throws takes down THAT WIRE, and nothing
289
+ else.** The handler runs inside the emit, so its exception used to surface in
290
+ whichever node emitted the signal — a `movementStateChanged → play` wire
291
+ naming a clip the sheet does not have switched off the character controller,
292
+ and the player stopped walking because an animation was missing. The wire is
293
+ reported once (naming the handler, not the emitter), disconnected, and counted
294
+ in `stats().errors`; every other wire on that signal keeps firing.
281
295
  - `engine.stats().errors` counts everything swallowed to stay alive. **A game
282
296
  that looks fine with `errors: 3` is a broken game** — read `engine.log`.
283
297
  - `engine.erroredNodes()` lists them; `engine.resumeErroredNodes()` puts them
@@ -348,6 +362,32 @@ ceiling — drive `engine.timeScale`, with `0.25× 0.5× 1× 2×` presets:
348
362
  number is refused and negatives clamp to 0: the scale multiplies every dt in
349
363
  the engine, so one NaN would take physics, timers and animation with it.
350
364
 
365
+ ### `bunx incanto-logs` — what the game is SAYING
366
+
367
+ `incanto-frame` gets the pixels out of the browser; this gets the words. The
368
+ engine writes diagnostics for exactly this — and until they had a channel out,
369
+ they reached a human only, in the debug overlay's logs panel.
370
+
371
+ ```
372
+ $ bunx incanto-logs
373
+ asset $characters/base never loaded: … responded with 404
374
+ [error] incanto: failed to load model '…/NO-SUCH-MODEL.glb'
375
+ 82 nodes · frame rate not measurable — the page is hidden (a window covered by
376
+ another counts), and the browser throttles it. Bring it to the front
377
+ ```
378
+
379
+ That game's grass, water and trees all rendered — `incanto-frame` called it
380
+ healthy (`subject fills 84.2%`) because they ARE there. Only the player was
381
+ missing. **Pixels and words answer different questions; a game can pass one and
382
+ fail the other**, which is why both are rungs.
383
+
384
+ It needs no render, so it answers from a page the browser has stopped drawing.
385
+ Exit 1 when something is wrong: an error logged, an error swallowed to keep the
386
+ game alive, or an asset that never loaded.
387
+
388
+ A repeated warning is folded into one line with a count (`×600`) — a game
389
+ shouting the same thing every frame has one problem, not six hundred.
390
+
351
391
  ### `bunx incanto-frame` — what is actually on screen
352
392
 
353
393
  The rung above framing. `framing` PREDICTS what the camera should see from the
@@ -386,6 +426,33 @@ browser, not in the dev server.** You do not have to tell it where anything is
386
426
  it reads the ports that are actually listening, asks each one whether it is an
387
427
  incanto dev server, and asks that page for a frame over vite's own HMR channel.
388
428
 
429
+ #### Drive it somewhere first
430
+
431
+ Every interesting state in a game is downstream of input, and the boot screen is
432
+ the only frame anything could capture before this:
433
+
434
+ ```
435
+ $ bunx incanto-frame --do "vector move 0 1; step 3000; vector move 0 0" --out bridge.png
436
+ frame 1280×720 · luminance 0.68
437
+ subject fills 21.4% of frame
438
+ drove 180 frames before capturing
439
+ wrote bridge.png — open it, or read it
440
+ ```
441
+
442
+ The same words `incanto-play` takes — `press`/`release ACTION`, `vector ACTION X Y`,
443
+ `key CODE down|up`, `pointer DX DY`, `step MS`, separated by semicolons or
444
+ newlines. `--do-file` reads a longer plan from a file. One vocabulary for the
445
+ headless gamepad and the live one.
446
+
447
+ `step` waits on the REAL clock: this is the running game, with its own loop, its
448
+ own physics and its own animation, and stepping it by hand would fight all three.
449
+
450
+ **`drove N frames` is the number to check.** A browser stops animating a tab it
451
+ considers hidden — including a window merely covered by another — and the inputs
452
+ then land on a game that never advances: the capture comes back UNCHANGED and
453
+ looks perfectly healthy. `drove 0 frames` is refused with an error rather than
454
+ handed over as a picture.
455
+
389
456
  #### Look at it
390
457
 
391
458
  ```
@@ -14,7 +14,7 @@
14
14
  "@dimforge/rapier2d-compat": "0.19.3",
15
15
  "@dimforge/rapier3d-compat": "0.19.3",
16
16
  "@pixiv/three-vrm": "^3.5.3",
17
- "incanto": "^0.47.0",
17
+ "incanto": "^0.49.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.47.0",
16
+ "incanto": "^0.49.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.47.0",
16
+ "incanto": "^0.49.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {