incanto 0.51.0 → 0.53.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 (67) hide show
  1. package/dist/2d.d.ts +86 -3
  2. package/dist/2d.js +3 -3
  3. package/dist/3d.d.ts +9 -3
  4. package/dist/3d.js +4 -4
  5. package/dist/{behavior-rZNfzVbH.d.ts → behavior-Do0Da56m.d.ts} +108 -0
  6. package/dist/{create-game-CkzKZ4v5.js → create-game-9D87XaiX.js} +112 -15
  7. package/dist/{create-game-BNaOC3Px.js → create-game-CDJ1lVqK.js} +21 -13
  8. package/dist/debug.d.ts +1 -1
  9. package/dist/debug.js +1 -1
  10. package/dist/{duplicate-EybyYeyL.js → duplicate-MNLMAcbz.js} +1 -1
  11. package/dist/editor.js +1931 -1579
  12. package/dist/{environment-presets-C08pOC6H.js → environment-presets-SkGanr2s.js} +3 -3
  13. package/dist/{gameplay-bStgtZBV.js → gameplay-CRYw_Q-T.js} +6 -4
  14. package/dist/gameplay.d.ts +1 -1
  15. package/dist/gameplay.js +1 -1
  16. package/dist/index.d.ts +237 -5
  17. package/dist/index.js +9 -9
  18. package/dist/{loader-B2asghWa.js → loader-BTkHYrQn.js} +86 -50
  19. package/dist/{loader-Dkbn56KC.d.ts → loader-Cu_7kJDy.d.ts} +1 -1
  20. package/dist/{log-report-lxrQY9cH.js → log-report-CPFm4OXf.js} +0 -0
  21. package/dist/net.d.ts +15 -410
  22. package/dist/net.js +1 -822
  23. package/dist/{pathfinding-Bz34pvQD.d.ts → pathfinding-HNGFqGUZ.d.ts} +24 -1
  24. package/dist/{physics-2d-Ccq2L9R0.js → physics-2d-BhZZ-HAp.js} +2 -2
  25. package/dist/{physics-3d-B2c5KNYh.js → physics-3d-DmyO2oaN.js} +3 -3
  26. package/dist/react.d.ts +1 -1
  27. package/dist/react.js +1 -1
  28. package/dist/{register-CGq-Hee8.js → register-BTomIiYG.js} +305 -25
  29. package/dist/{register-B_BaaGx-.js → register-DROK2l7J.js} +3 -3
  30. package/dist/{registry-IyWCGe4q.js → registry-C7u42TID.js} +23 -1
  31. package/dist/{replay-DIP2_as4.d.ts → replay-9Fy6C10F.d.ts} +1 -1
  32. package/dist/{replay-BgKxGcXH.js → replay-DYNUL4BU.js} +195 -2
  33. package/dist/split-screen-CaF9hO7g.js +1267 -0
  34. package/dist/split-screen-DNmcX1Pz.d.ts +441 -0
  35. package/dist/{src-C3UwzYXl.js → src-C9xyZW7M.js} +1 -1
  36. package/dist/{teardown-BKTCzLek.js → teardown-Bw2aeyGI.js} +32 -2
  37. package/dist/{test-D2gRpk5V.js → test-CJRsciFk.js} +119 -22
  38. package/dist/test.d.ts +60 -5
  39. package/dist/test.js +3 -3
  40. package/dist/{touch-BoNg_MnF.js → touch-BnMyy9tr.js} +4 -4
  41. package/dist/vite.js +2 -2
  42. package/editor/assets/{agent8-DbX_msaO.js → agent8-PlFHzJsh.js} +1 -1
  43. package/editor/assets/{debug-C-kMxdl1.js → debug-CebV7CDW.js} +1 -1
  44. package/editor/assets/index-CdbsY31G.js +10958 -0
  45. package/editor/index.html +1 -1
  46. package/package.json +1 -1
  47. package/schemas/scene.schema.json +213 -0
  48. package/skills/incanto-assets.md +4 -1
  49. package/skills/incanto-audio.md +97 -10
  50. package/skills/incanto-building-2d-games.md +20 -3
  51. package/skills/incanto-editor.md +48 -11
  52. package/skills/incanto-gameplay-behaviors.md +14 -5
  53. package/skills/incanto-hud.md +7 -0
  54. package/skills/incanto-multiplayer.md +39 -3
  55. package/skills/incanto-node-reference.md +34 -0
  56. package/skills/incanto-physics-and-input.md +38 -0
  57. package/skills/incanto-verifying-your-game.md +75 -1
  58. package/skills/incanto-web-integration.md +1 -0
  59. package/templates/agent8-server.ts +79 -2
  60. package/templates-app/beacon-isle-3d/index.html +3 -3
  61. package/templates-app/beacon-isle-3d/package.json +1 -1
  62. package/templates-app/tps-3d/index.html +3 -3
  63. package/templates-app/tps-3d/package.json +1 -1
  64. package/templates-app/village-quest-3d/index.html +3 -3
  65. package/templates-app/village-quest-3d/package.json +1 -1
  66. package/dist/register-CLVhzWcI.js +0 -374
  67. package/editor/assets/index-CUc1U7wm.js +0 -10951
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-CUc1U7wm.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-CdbsY31G.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.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -352,6 +352,9 @@
352
352
  {
353
353
  "$ref": "#/$defs/UiLanguageSelect"
354
354
  },
355
+ {
356
+ "$ref": "#/$defs/UiMuteToggle"
357
+ },
355
358
  {
356
359
  "$ref": "#/$defs/UiPanel"
357
360
  },
@@ -373,6 +376,9 @@
373
376
  {
374
377
  "$ref": "#/$defs/UiToggle"
375
378
  },
379
+ {
380
+ "$ref": "#/$defs/UiVolumeSlider"
381
+ },
376
382
  {
377
383
  "$ref": "#/$defs/VoxelGrid3D"
378
384
  },
@@ -6870,6 +6876,97 @@
6870
6876
  },
6871
6877
  "required": ["name", "type"]
6872
6878
  },
6879
+ "UiMuteToggle": {
6880
+ "type": "object",
6881
+ "x-signals": ["dragStarted", "dragCancelled", "droppedOn", "dropped", "changed"],
6882
+ "properties": {
6883
+ "name": {
6884
+ "type": "string"
6885
+ },
6886
+ "uid": {
6887
+ "type": "string"
6888
+ },
6889
+ "type": {
6890
+ "const": "UiMuteToggle"
6891
+ },
6892
+ "groups": {
6893
+ "type": "array",
6894
+ "items": {
6895
+ "type": "string"
6896
+ }
6897
+ },
6898
+ "tags": {
6899
+ "type": "object"
6900
+ },
6901
+ "props": {
6902
+ "type": "object",
6903
+ "properties": {
6904
+ "anchor": {
6905
+ "type": "string",
6906
+ "enum": [
6907
+ "topLeft",
6908
+ "top",
6909
+ "topRight",
6910
+ "left",
6911
+ "center",
6912
+ "right",
6913
+ "bottomLeft",
6914
+ "bottom",
6915
+ "bottomRight"
6916
+ ],
6917
+ "default": "topLeft"
6918
+ },
6919
+ "visible": {
6920
+ "type": "boolean",
6921
+ "default": true
6922
+ },
6923
+ "focusable": {
6924
+ "type": "boolean",
6925
+ "default": true
6926
+ },
6927
+ "draggable": {
6928
+ "type": "boolean",
6929
+ "default": false
6930
+ },
6931
+ "dropTarget": {
6932
+ "type": "boolean",
6933
+ "default": false
6934
+ },
6935
+ "label": {
6936
+ "type": "string",
6937
+ "default": ""
6938
+ },
6939
+ "value": {
6940
+ "type": "boolean",
6941
+ "default": false
6942
+ }
6943
+ },
6944
+ "additionalProperties": false
6945
+ },
6946
+ "script": {
6947
+ "type": "object",
6948
+ "properties": {
6949
+ "name": {
6950
+ "type": "string"
6951
+ },
6952
+ "props": {
6953
+ "type": "object"
6954
+ }
6955
+ },
6956
+ "required": ["name"]
6957
+ },
6958
+ "network": {
6959
+ "type": "object"
6960
+ },
6961
+ "children": {
6962
+ "type": "array",
6963
+ "items": {
6964
+ "$ref": "#/$defs/node"
6965
+ }
6966
+ }
6967
+ },
6968
+ "required": ["name", "type"]
6969
+ },
6873
6970
  "UiPanel": {
6874
6971
  "type": "object",
6875
6972
  "x-signals": ["dragStarted", "dragCancelled", "droppedOn", "dropped"],
@@ -7580,6 +7677,122 @@
7580
7677
  },
7581
7678
  "required": ["name", "type"]
7582
7679
  },
7680
+ "UiVolumeSlider": {
7681
+ "type": "object",
7682
+ "x-signals": ["dragStarted", "dragCancelled", "droppedOn", "dropped", "changed"],
7683
+ "properties": {
7684
+ "name": {
7685
+ "type": "string"
7686
+ },
7687
+ "uid": {
7688
+ "type": "string"
7689
+ },
7690
+ "type": {
7691
+ "const": "UiVolumeSlider"
7692
+ },
7693
+ "groups": {
7694
+ "type": "array",
7695
+ "items": {
7696
+ "type": "string"
7697
+ }
7698
+ },
7699
+ "tags": {
7700
+ "type": "object"
7701
+ },
7702
+ "props": {
7703
+ "type": "object",
7704
+ "properties": {
7705
+ "anchor": {
7706
+ "type": "string",
7707
+ "enum": [
7708
+ "topLeft",
7709
+ "top",
7710
+ "topRight",
7711
+ "left",
7712
+ "center",
7713
+ "right",
7714
+ "bottomLeft",
7715
+ "bottom",
7716
+ "bottomRight"
7717
+ ],
7718
+ "default": "topLeft"
7719
+ },
7720
+ "visible": {
7721
+ "type": "boolean",
7722
+ "default": true
7723
+ },
7724
+ "focusable": {
7725
+ "type": "boolean",
7726
+ "default": true
7727
+ },
7728
+ "draggable": {
7729
+ "type": "boolean",
7730
+ "default": false
7731
+ },
7732
+ "dropTarget": {
7733
+ "type": "boolean",
7734
+ "default": false
7735
+ },
7736
+ "label": {
7737
+ "type": "string",
7738
+ "default": ""
7739
+ },
7740
+ "value": {
7741
+ "type": "number",
7742
+ "default": 0.5
7743
+ },
7744
+ "min": {
7745
+ "type": "number",
7746
+ "default": 0
7747
+ },
7748
+ "max": {
7749
+ "type": "number",
7750
+ "default": 1
7751
+ },
7752
+ "step": {
7753
+ "type": "number",
7754
+ "default": 0.01
7755
+ },
7756
+ "width": {
7757
+ "type": "number",
7758
+ "default": 180
7759
+ },
7760
+ "color": {
7761
+ "type": "string",
7762
+ "default": "#6ee7dc"
7763
+ },
7764
+ "bus": {
7765
+ "type": "string",
7766
+ "enum": ["master", "sfx", "music"],
7767
+ "default": "master"
7768
+ }
7769
+ },
7770
+ "additionalProperties": false
7771
+ },
7772
+ "script": {
7773
+ "type": "object",
7774
+ "properties": {
7775
+ "name": {
7776
+ "type": "string"
7777
+ },
7778
+ "props": {
7779
+ "type": "object"
7780
+ }
7781
+ },
7782
+ "required": ["name"]
7783
+ },
7784
+ "network": {
7785
+ "type": "object"
7786
+ },
7787
+ "children": {
7788
+ "type": "array",
7789
+ "items": {
7790
+ "$ref": "#/$defs/node"
7791
+ }
7792
+ }
7793
+ },
7794
+ "required": ["name", "type"]
7795
+ },
7583
7796
  "VoxelGrid3D": {
7584
7797
  "type": "object",
7585
7798
  "x-signals": ["blocksChanged"],
@@ -164,4 +164,7 @@ Two consequences worth knowing:
164
164
 
165
165
  A texture that 404s now shows up in `game.assetErrors()` alongside models, by the
166
166
  URL you wrote — so "why is my sprite invisible" is answerable without opening the
167
- network tab.
167
+ network tab. In **2D** the same question is `renderer.assets.errors()`
168
+ (`$ref`, url and reason per failed entry), and the scene EDITOR reads it: a
169
+ failed asset is red in the explorer with the url in its tooltip and the
170
+ consequence in its inspector.
@@ -33,7 +33,9 @@ Set `preset` to one of the names below and call `play()`. No files, no loading.
33
33
  ```
34
34
 
35
35
  ```ts
36
- const coin = scene.tree.root.getNode('Coin'); // an AudioPlayer
36
+ import type { AudioPlayer } from 'incanto';
37
+ // from a Behavior — `getNode` returns a `Node`, so name the type you asked for
38
+ const coin = this.node.getNode('Coin') as AudioPlayer;
37
39
  coin.play(); // synthesizes + plays instantly; rapid calls OVERLAP (no cutoff)
38
40
  ```
39
41
 
@@ -145,8 +147,20 @@ presets are fire-and-forget one-shots: they do not emit `finished`.
145
147
  Browsers block audio before the first user gesture. `autoplay: true` (or any
146
148
  early `play()`) that gets blocked is marked pending; **`createGame2D` /
147
149
  `createGame3D` automatically retry every pending player AND resume the WebAudio
148
- SFX context on the first pointerdown/keydown** — you don't wire anything. A
149
- blocked play is not an error; it just waits for the gesture.
150
+ SFX context on the first gesture anywhere on the page** — you don't wire
151
+ anything, and a tap on the on-screen touch controls counts (they sit above the
152
+ canvas, not on it). A blocked play is not an error; it just waits.
153
+
154
+ **A file the browser CANNOT play is a different thing, and it says so.** A 404 or
155
+ an undecodable clip used to be filed as "waiting for a gesture" too, so it
156
+ retried forever in silence — and a quiet game looks exactly like a game with no
157
+ sound in it. Now it lands in the engine log and in
158
+ **`game.assetErrors()`** (which 2D games can also ask now), beside the textures
159
+ and models that 404'd:
160
+
161
+ ```
162
+ [incanto] AudioPlayer 'Boom' could not load '/audio/explosion.mp3' — it is silent.
163
+ ```
150
164
 
151
165
  ### Music: loop a `src` clip on the music bus
152
166
 
@@ -227,7 +241,26 @@ the node declares (default `sfx`); music-ish loops typically set `bus: "music"`.
227
241
  Changes apply to currently-playing `src` clips on the next frame and to every new
228
242
  sound immediately.
229
243
 
230
- A settings slider just writes these numbers — no per-node bookkeeping.
244
+ A settings slider just writes these numbers — no per-node bookkeeping. And
245
+ **the slider is a node**, so an audio menu is scene JSON like everything else:
246
+
247
+ ```json
248
+ { "name": "Options", "type": "UiPanel", "props": { "anchor": "center" }, "children": [
249
+ { "name": "Music", "type": "UiVolumeSlider", "props": { "bus": "music" } },
250
+ { "name": "Sound", "type": "UiVolumeSlider", "props": { "bus": "sfx" } },
251
+ { "name": "Mute", "type": "UiMuteToggle" }
252
+ ] }
253
+ ```
254
+
255
+ | node | prop | writes |
256
+ |---|---|---|
257
+ | `UiVolumeSlider` | `bus`: `master` (default) / `sfx` / `music` | `engine.audio[bus]` |
258
+ | `UiMuteToggle` | — | `engine.audio.muted` (checked = silent) |
259
+
260
+ No behavior, no `connections`, nothing to save: the buses persist themselves
261
+ (below), and each control follows a change made anywhere else, so two menus can
262
+ never disagree. Left unlabelled a slider names its own bus (`Volume` / `Sound` /
263
+ `Music`, translated when the scene declares `settings.volume.*`).
231
264
 
232
265
  ---
233
266
 
@@ -312,15 +345,44 @@ clean fit; the looping-`src` `AudioPlayer` (§2) stays the right tool for a *fix
312
345
  background loop authored in scene JSON. Wire `crossfadeTo` from gameplay:
313
346
 
314
347
  ```ts
315
- // e.g. in a behavior when the boss spawns:
316
- this.tree.engine.music.crossfadeTo('incanto/assets/audio/boss.mp3', 3);
348
+ // e.g. in a behavior when the boss spawns (`this.engine` is the accessor a
349
+ // Behavior has — there is no `this.tree`):
350
+ this.engine.music.crossfadeTo('/audio/boss.mp3', 3);
317
351
  ```
318
352
 
319
- > Large music files are NOT bundled — reference them by URL (see §4). Headless:
320
- > the manager's state machine still runs (`current` updates) but plays nothing.
353
+ > Large music files are NOT bundled — reference them by URL (see §4).
354
+
355
+ **Headless the state machine still runs and `current` still updates** — nothing
356
+ plays, but the question a test has is answerable:
357
+
358
+ ```ts
359
+ session.engine.music.crossfadeTo('/audio/boss.wav', 2);
360
+ session.step(100);
361
+ session.engine.music.current; // '/audio/boss.wav'
362
+ session.engine.audio.countOf('/audio/boss.wav'); // 1
363
+ ```
321
364
 
322
365
  ---
323
366
 
367
+ ## Sound stops when the thing making it goes away
368
+
369
+ Teardown is SILENCE, and you do not wire it:
370
+
371
+ - **A freed node stops its clip.** Swap to level 2 and level 1's looping
372
+ background `AudioPlayer` stops with it — it used to play on over the new level
373
+ forever, with its node freed and no handle left to stop it. Being *moved* is
374
+ not this: a reparented node keeps playing, because reparenting is not
375
+ destroying.
376
+ - **`game.dispose()` stops the music and every continuous voice**, and hands the
377
+ AudioContexts back. An SPA that mounts the game a few times would otherwise
378
+ run out of them (browsers allow only a handful per page).
379
+
380
+ So the one thing you still own is a voice you want to outlive a node — and the
381
+ one thing you must NOT rely on is a sound stopping itself because the scene
382
+ changed under it. `engine.music` deliberately survives a scene swap (it belongs
383
+ to the engine, not the scene): call `engine.music.stop(1)` or `crossfadeTo` when
384
+ the music should change.
385
+
324
386
  ## Decision guide
325
387
 
326
388
  - **Need a quick game sound (coin/jump/hit/explosion/…)** → set `preset`. Done.
@@ -332,8 +394,33 @@ this.tree.engine.music.crossfadeTo('incanto/assets/audio/boss.mp3', 3);
332
394
  - **3D sound that pans + fades with distance** → `spatial:true` on an
333
395
  `AudioPlayer` in a 3D scene (§2b); listener = the active `Camera3D`.
334
396
  - **Global volume / mute / settings** → `engine.audio.master/sfx/music/muted`.
335
- - **Verifying headlessly** → audio is a no-op in the VM; assert `play()` doesn't
336
- throw and check your gameplay/score logic instead (see incanto-verifying).
397
+ - **Verifying headlessly** → read the AUDIO RECORD (below). Audio makes no sound
398
+ in the VM, but the calls still happen, so "did the coin sound fire when the
399
+ coin was collected?" is answerable there.
400
+
401
+ ## Verifying sound without hearing it
402
+
403
+ ```ts
404
+ const session = await createPlaySession(gameJson, {});
405
+ session.engine.audio.clearLog();
406
+ collectACoin();
407
+ session.step(200);
408
+
409
+ session.engine.audio.countOf('coin'); // 1
410
+ session.engine.audio.recent();
411
+ // [{ kind: 'preset', name: 'coin', from: '/Game/Player/Coin', bus: 'sfx', at: 1.2 }]
412
+ ```
413
+
414
+ `recent()` is the last 200 sounds, oldest first — `kind` is `preset` | `src` |
415
+ `music` | `voice`, `name` is the preset name, the clip url or the track,
416
+ `from` is the node path (or `engine.music` / `engine.sfx`), and `bus` is where
417
+ its volume comes from. `countOf(name)` is the assertion you usually want;
418
+ `clearLog()` resets between steps.
419
+
420
+ This covers every path: `AudioPlayer.play()` on both the procedural and the
421
+ `src` route, `engine.music.play`/`crossfadeTo`, and `engine.sfx.startVoice`. It
422
+ records the INTENT to play — that the wiring fired — not that a speaker moved;
423
+ for "the file is broken" see `assetErrors()` above.
337
424
 
338
425
  ## Settings that survive a reload (`engine.settings`)
339
426
 
@@ -200,9 +200,26 @@ listing the valid set. With a viewport design, UI coordinates are design px.
200
200
  **incanto-audio.md**.
201
201
  - Reference example: [examples/2d-phaser-sprite-character-gravity](https://github.com/rareboe/Incanto/tree/main/examples/2d-phaser-sprite-character-gravity) —
202
202
  spritesheet character with a walk/jump/attack behavior state machine.
203
- - Pointer→world mapping (the InputMap covers keyboard actions; pointer projection is
204
- this one-liner):
205
- `world = camera.clampedCenter(w,h) + (pointer - viewport/2) / camera.zoom`.
203
+ - **Tap / drag / click**: `createGame2D({ pointer: true })` attaches pointer
204
+ input — MOUSE, FINGER and pen alike — and then
205
+
206
+ ```ts
207
+ game.engine.updated.connect(() => {
208
+ if (!game.engine.input.mouseJustPressed()) return;
209
+ const at = game.engine.input.pointerPosition(); // canvas px, null until
210
+ if (!at) return; // a pointer has been seen
211
+ const world = game.renderer.worldFromScreen(at.x, at.y); // world px
212
+ popAt(world);
213
+ });
214
+ ```
215
+
216
+ Use `renderer.worldFromScreen` — do NOT hand-roll the projection. It already
217
+ accounts for the design `viewport`, the letterbox bars and the camera's
218
+ clamped centre; a formula written against the DESIGN rect is off by
219
+ `(design − canvas) / 2 / zoom`, which on a 390 px-wide phone showing a 480 px
220
+ design measured **62 world px** — a finger-and-a-half from what the player
221
+ touched. Its inverse, `renderer.screenFromWorld(wx, wy)`, pins DOM to the
222
+ world (see incanto-web-integration).
206
223
 
207
224
  ## Game flow recipes
208
225
 
@@ -39,13 +39,17 @@ The `scenes` button opens the project as a tree, not a list of paths:
39
39
  - **The scene you are editing** is marked and revealed, and the cursor starts on it.
40
40
  - **A filter box** — type any part of a path; matches show wherever they are hiding.
41
41
  Keyboard from that box: `↑↓` move · `→` open a folder · `←` close it · `⏎` load ·
42
- `Esc` close (backdrop and ✕ work too).
42
+ `Esc` close (backdrop and ✕ work too). The cursor always sits on a SCENE —
43
+ the first match while you filter, the scene you are editing when you open the
44
+ panel — so `⏎` loads something without arrowing first.
43
45
  - Each row carries **when it was last written and how big it is**, which is usually
44
46
  how you recognise the file you were just in.
45
47
  - **create** makes a new scene at the path in the box — left empty it uses the
46
48
  placeholder, which tracks the folder you are standing in, so a scene lands beside
47
49
  its siblings rather than at the project root. Parent dirs are created.
48
- - Loading another scene while you have unsaved EDITS asks first.
50
+ - Loading another scene while you have unsaved EDITS asks first, and the answer
51
+ is not only *discard*: **save & open** writes the scene you are leaving and
52
+ then opens the other one.
49
53
 
50
54
  **The same browser opens inside a running game.** When the game's dev server serves
51
55
  the project's scenes, `☰ debug ▸ ✎ edit this scene` gives you the whole project: the
@@ -66,7 +70,12 @@ is not there. The editor still edits the scene the game booted with meanwhile.
66
70
  Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
67
71
 
68
72
  - **Explorer** — two collapsible sections: **ASSETS** on top (icon rows by
69
- type; keys with a `group/` prefix nest under collapsible folders (any depth),
73
+ type; **an asset the renderer could not FETCH turns red**, with the url in its
74
+ tooltip and the consequence spelled out when you select it — *"failed to
75
+ load — every node using $fx/coin draws nothing"* — plus a banner naming all of
76
+ them. A 404'd texture is otherwise the one failure with no symptom: the scene
77
+ is structurally perfect, the tree is full, and the viewport draws nothing where
78
+ the art should be; keys with a `group/` prefix nest under collapsible folders (any depth),
70
79
  each showing its recursive asset count — refs are `$group/key`; click the
71
80
  icon for a blurb, the row to edit in the inspector; DRAG asset rows onto a
72
81
  folder (or the section background = root) to move them — references rewrite
@@ -79,7 +88,14 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
79
88
  to reorder before/after. Dragging a selected row moves the whole selection.
80
89
  Illegal drops (engine rules — e.g. a CharacterController2D outside a
81
90
  CharacterBody2D) are ROLLED BACK entirely with the error in the banner; the
82
- tree never shows a state the engine would reject. Right-click for
91
+ tree never shows a state the engine would reject.
92
+ **A reparent does not MOVE anything**: the node keeps its world transform and
93
+ the editor rewrites the local `position`/`rotation`/`scale` to match (drop a
94
+ ball at `[3, 0.6, 0]` onto a crate at `[-3, 0.5, 0]` and the ball stays put,
95
+ holding `[6, 0.1, 0]`). Rotated and scaled parents are handled the same way,
96
+ and the values that are no longer needed disappear rather than being written
97
+ as defaults. This is the Godot/Unity/Blender behaviour, and it is what keeps a
98
+ tree edit from changing the picture. Right-click for
83
99
  duplicate / rename (or double-click the name) / cut / copy / paste-as-child /
84
100
  delete — all act on the multi-selection. **Rename REPAIRS references**: every
85
101
  `connections[].from/to` and every node-path prop pointing at the node (or into
@@ -121,6 +137,12 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
121
137
  scales to 0.25 steps (the readout shows the snapped value).
122
138
  2D additionally supports click-pick, body drag, wheel zoom-at-cursor,
123
139
  right/middle/Shift-drag pan, Alt+wheel scale, and collider wireframes.
140
+ **HUD nodes are editable like anything else**: a `UILayer` subtree is posed in
141
+ screen space rather than world space, and the viewport now picks, outlines and
142
+ drags it there — click the widget where you SEE it, and its position moves 1:1
143
+ with the cursor whatever the world zoom is. (Picking used to look only at the
144
+ world pass, so a HUD could not be selected in the viewport at all, and its
145
+ outline was drawn wherever the game camera happened to be looking.)
124
146
  **`F` frames the SELECTED node** (its whole subtree; a light or empty node has
125
147
  no bounds, so the camera goes to it at a readable distance) — and the whole
126
148
  scene when nothing is selected, which is the Maya/Unity/Unreal meaning of the
@@ -136,9 +158,25 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
136
158
  - **Inspector** — schema-driven from the node registry, with STRUCTURED editors for
137
159
  the hard parts: `collider` (shape dropdown + per-shape dimensions, mirrored by
138
160
  the wireframe), `network` (mode dropdown + sync-key chips + throttle),
139
- `script` (attach/detach + name + props, with copy-paste Behavior boilerplate in
140
- its help), `groups` (tag chips). Every one has a `?` help popover with examples.
141
- Values equal to the default are removed (delta-only, like the serializer).
161
+ `script` — **the behaviors the engine SHIPS are a dropdown**, and picking one
162
+ builds a form from its own prop schema (a `Health` gets `max`/`regenPerSec`/
163
+ `invulnerableFor`/`freeOnDeath` with their defaults; a `Patrol`'s `mode` is a
164
+ `loop`/`pingpong` menu), delta-only like every other field. A name that is NOT
165
+ built in is your game's TypeScript: it keeps the raw-JSON props box and says
166
+ so, with copy-paste Behavior boilerplate in its help — and `groups` (tag
167
+ chips). Every one has a `?` help popover with examples. Values equal to the
168
+ default are removed (delta-only, like the serializer).
169
+ **A prop that holds a NODE PATH** (`Chase.target`, `Camera2D.follow`,
170
+ `Spawner.prefab`, `Joint3D.target`, `skinPath`, `terrain`…) offers every node
171
+ in the scene as a list — `%Name` where the name is unique, the absolute path
172
+ where it repeats — and free text still works for the forms a list cannot
173
+ enumerate (`../Skin`, a path into a subtree). A value that resolves to NOTHING
174
+ is marked red with the reason: `'%Playerr' matches no node in this scene. The
175
+ scene still loads — the prop just does nothing.` That last sentence is the
176
+ point: unlike a connection, a dangling path prop is not a load error, so
177
+ nothing else would ever have told you. A behavior's path props are checked
178
+ exactly like the node's own, and `incanto-check` reports the same thing from
179
+ the file.
142
180
 
143
181
  **3D scenes** get full camera navigation: drag orbits, right/middle/Shift-drag pans,
144
182
  wheel zooms, `F` frames the contents, and **`0` / the `game cam` button** returns to the
@@ -353,8 +391,7 @@ get normal click-select back.
353
391
  The **✦ button** beside the add-node controls opens the Generate dialog — the
354
392
  `incanto/env` generators inside the editor, driven by the same `GENERATORS`
355
393
  catalog as the `incanto-env` CLI and filtered to the open scene's dimension
356
- (3D: arena, terrain, meadow, forest, maze, rocks, clouds, island; 2D:
357
- platforms2d, maze2d, dungeon2d). The param form is built from the catalog
394
+ (3D: arena, terrain, maze; 2D: platforms2d, maze2d, dungeon2d). The param form is built from the catalog
358
395
  metadata — numbers clamp to their min/max, option lists become dropdowns — so
359
396
  a new generator needs zero editor changes. The seed starts random (↻ rerolls);
360
397
  the same seed always generates the same level. **insert** runs the generator
@@ -395,7 +432,7 @@ trusts the surrounding network — use it only inside containers.
395
432
  The EDIT view freezes game time — nothing falls or fires until you press play;
396
433
  only ambient visuals (model animations, particles, water, foliage sway) keep
397
434
  moving. The PLAY view simulates everything engine-native but cannot execute the
398
- game's TypeScript behaviors. 3D scenes render and edit via the inspector; direct
399
- viewport manipulation is 2D-only for now. Runtime-injected textures (asset URLs
435
+ game's TypeScript behaviors. Both dimensions have the W/E/R gizmos in the
436
+ viewport; 2D additionally has click-pick, body drag and wheel zoom-at-cursor. Runtime-injected textures (asset URLs
400
437
  like `"GENERATED_AT_RUNTIME"`) render as a magenta checkerboard — position/size
401
438
  stay visible; the real art appears in the running game.
@@ -58,11 +58,20 @@ Two kinds of node path resolve from DIFFERENT origins — mixing them up is the
58
58
  enemy (a child of the Spawner) reaches the player with the ABSOLUTE
59
59
  `/root/Player`, never a bare `Player` (which would look under the enemy).
60
60
 
61
- Renaming a node in the editor now REWRITES both kinds for you (and reports what
62
- it changed), so the paths above stay valid across a rename. Hand-edited JSON
63
- still has to be kept in sync yourself — `bunx incanto-check` catches the
64
- connections, but a behavior prop pointing at a name that no longer exists
65
- resolves to null in silence.
61
+ Renaming a node in the editor REWRITES both kinds for you (behavior props
62
+ included) and reports what it changed, and deleting a node lists every reference
63
+ that still points at it before it goes. Hand-edited JSON is covered too:
64
+ **`bunx incanto-check` now reports a node-path prop that leads nowhere** —
65
+
66
+ ```
67
+ warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
68
+ The scene loads and the prop does nothing.
69
+ ```
70
+
71
+ — which is the one failure that used to be completely silent (a prop path
72
+ resolves with `getNodeOrNull`, so the scene opens and the enemy just never
73
+ chases). `Chase.target` and `FollowCamera.target` are `required`: an EMPTY one
74
+ is a load error naming the node, not a surprise in the browser.
66
75
 
67
76
  **Never write `/root/<RootName>/...`.** If your root node is named `Game`, the
68
77
  path is `/root/Player` — NOT `/root/Game/Player`: `/root/` already *is* the
@@ -151,6 +151,13 @@ so structure in the tree becomes structure on screen. Panels nest.
151
151
  | `UiSlider` | `label`, `value`, `min`, `max`, `step`, `width`, `color` | `changed(value)` |
152
152
  | `UiToggle` | `label`, `value` | `changed(bool)` |
153
153
  | `UiSelect` | `label`, `options` (`"low,medium,high"`), `value` | `changed(value)` |
154
+ | `UiVolumeSlider` | `bus` (`master`/`sfx`/`music`), plus every `UiSlider` prop | `changed(value)` |
155
+ | `UiMuteToggle` | `label` | `changed(bool)` |
156
+
157
+ The last two are **already wired**: they read and write `engine.audio`, which
158
+ persists itself. An audio menu is three nodes and no TypeScript — see
159
+ `incanto-audio.md`. The graphics ones (`UiQualitySelect`, `UiFrameCapSelect`,
160
+ `UiRenderScaleSelect`) and `UiLanguageSelect` work the same way.
154
161
 
155
162
  **An inventory is a grid panel**: `"layout": "grid", "columns": 5`, one child per
156
163
  slot, each a small `UiPanel` holding a `UiImage` (`tint` greys out what you