incanto 0.60.0 → 0.62.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 (68) hide show
  1. package/bin/incanto-check.mjs +13 -0
  2. package/bin/incanto-verify.mjs +134 -55
  3. package/dist/2d.d.ts +64 -3
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +5 -5
  6. package/dist/3d.js +5 -4
  7. package/dist/{pathfinding-BqWBb0kh.d.ts → audio-player-D5GJgb_x.d.ts} +103 -38
  8. package/dist/{behavior-DWKTUzKI.d.ts → behavior-DsgayMsH.d.ts} +79 -1
  9. package/dist/{create-game-ClnIb_M5.js → create-game-BpunnGPX.js} +78 -13
  10. package/dist/{create-game-BCm38FJV.js → create-game-Caut3bqN.js} +21 -294
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/debug.js +1 -1
  13. package/dist/{duplicate-DJQd44CD.js → duplicate-E4FUs5Bn.js} +1 -1
  14. package/dist/editor.js +41 -19
  15. package/dist/{environment-presets-8cjF3t6w.js → environment-presets-BAWeOeqf.js} +50 -13
  16. package/dist/{gameplay-BBEjPFsR.js → gameplay-CaHqDiQD.js} +177 -24
  17. package/dist/gameplay.d.ts +1 -1
  18. package/dist/gameplay.js +1 -1
  19. package/dist/index.d.ts +97 -6
  20. package/dist/index.js +7 -7
  21. package/dist/{loader-D8n7TU8W.js → loader-DEe272nY.js} +900 -2
  22. package/dist/{loader-TvkRFbyL.d.ts → loader-DolLJWJn.d.ts} +13 -1
  23. package/dist/net.d.ts +2 -2
  24. package/dist/net.js +1 -1
  25. package/dist/pathfinding-_fGrCFmH.d.ts +28 -0
  26. package/dist/{physics-2d-DqdVp1bt.js → physics-2d-BXmu2i7W.js} +11 -3
  27. package/dist/{physics-3d-BP0DZb_1.js → physics-3d-ClxP6Uv7.js} +121 -17
  28. package/dist/react.d.ts +1 -1
  29. package/dist/react.js +1 -1
  30. package/dist/{register-BSu2dWGC.js → register-CNh4FlbD.js} +104 -16
  31. package/dist/{register-Da3hXh2H.js → register-D3yx8D4r.js} +228 -16
  32. package/dist/{registry-WWcQcfMr.js → registry-CF70EArN.js} +55 -3
  33. package/dist/{replay-BCMK_VRP.d.ts → replay-C5x2vPF5.d.ts} +2 -2
  34. package/dist/{replay-BlNuIDdg.js → replay-DlgHItNv.js} +57 -197
  35. package/dist/{split-screen-CL5Yvxse.js → split-screen-CBM9wcMX.js} +3 -3
  36. package/dist/{split-screen-BQ3tAsf-.d.ts → split-screen-D7OopelJ.d.ts} +2 -2
  37. package/dist/{src-DFpXBMJN.js → src-CH00_JsR.js} +1 -1
  38. package/dist/{teardown-CTTwhWSe.js → teardown-C7qP-dcC.js} +1 -1
  39. package/dist/{test-BeZ95pqw.js → test-BMkg8zMV.js} +29 -16
  40. package/dist/test.d.ts +45 -4
  41. package/dist/test.js +2 -2
  42. package/dist/vite.js +2 -2
  43. package/editor/assets/{agent8-DSJries_.js → agent8-m5mtAO_A.js} +1 -1
  44. package/editor/assets/{debug-D15Wi5TO.js → debug-CPhzCT8f.js} +1 -1
  45. package/editor/assets/{index-BjC88k97.js → index-D422P4kW.js} +92 -92
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +91 -0
  49. package/skills/incanto-3d-models.md +1 -1
  50. package/skills/incanto-assets.md +10 -1
  51. package/skills/incanto-audio.md +21 -13
  52. package/skills/incanto-behaviors-and-scripts.md +44 -3
  53. package/skills/incanto-building-2d-games.md +32 -4
  54. package/skills/incanto-building-3d-games.md +1 -1
  55. package/skills/incanto-editor.md +1 -1
  56. package/skills/incanto-gameplay-behaviors.md +39 -2
  57. package/skills/incanto-hud.md +5 -3
  58. package/skills/incanto-localization.md +7 -0
  59. package/skills/incanto-node-reference.md +12 -0
  60. package/skills/incanto-physics-and-input.md +23 -2
  61. package/skills/incanto-scene-json-authoring.md +6 -1
  62. package/skills/incanto-verifying-your-game.md +31 -1
  63. package/templates-app/beacon-isle-3d/package.json +1 -1
  64. package/templates-app/platformer-2d/package.json +1 -1
  65. package/templates-app/star-survivor/package.json +1 -1
  66. package/templates-app/tps-3d/package.json +1 -1
  67. package/templates-app/village-quest-3d/package.json +1 -1
  68. package/dist/particle-sim-C5OfBbmU.d.ts +0 -77
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-BjC88k97.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-D422P4kW.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.60.0",
3
+ "version": "0.62.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -295,6 +295,9 @@
295
295
  {
296
296
  "$ref": "#/$defs/Particles3D"
297
297
  },
298
+ {
299
+ "$ref": "#/$defs/Respawn"
300
+ },
298
301
  {
299
302
  "$ref": "#/$defs/RigidBody2D"
300
303
  },
@@ -3696,6 +3699,10 @@
3696
3699
  "align": {
3697
3700
  "type": "string",
3698
3701
  "default": "left"
3702
+ },
3703
+ "opacity": {
3704
+ "type": "number",
3705
+ "default": 1
3699
3706
  }
3700
3707
  },
3701
3708
  "additionalProperties": false
@@ -5323,6 +5330,90 @@
5323
5330
  },
5324
5331
  "required": ["name", "type"]
5325
5332
  },
5333
+ "Respawn": {
5334
+ "type": "object",
5335
+ "x-signals": ["respawned"],
5336
+ "properties": {
5337
+ "name": {
5338
+ "type": "string"
5339
+ },
5340
+ "uid": {
5341
+ "type": "string"
5342
+ },
5343
+ "type": {
5344
+ "const": "Respawn"
5345
+ },
5346
+ "groups": {
5347
+ "type": "array",
5348
+ "items": {
5349
+ "type": "string"
5350
+ }
5351
+ },
5352
+ "tags": {
5353
+ "type": "object"
5354
+ },
5355
+ "props": {
5356
+ "type": "object",
5357
+ "properties": {
5358
+ "target": {
5359
+ "type": "string",
5360
+ "description": "A node path: '%UniqueName', '/Absolute/From/Root', or relative to this node ('../Skin').",
5361
+ "default": ".."
5362
+ },
5363
+ "below": {
5364
+ "default": null
5365
+ },
5366
+ "to": {
5367
+ "type": "array",
5368
+ "default": []
5369
+ },
5370
+ "resetVelocity": {
5371
+ "type": "boolean",
5372
+ "default": true
5373
+ }
5374
+ },
5375
+ "additionalProperties": false
5376
+ },
5377
+ "script": {
5378
+ "type": "object",
5379
+ "properties": {
5380
+ "name": {
5381
+ "type": "string"
5382
+ },
5383
+ "props": {
5384
+ "type": "object"
5385
+ }
5386
+ },
5387
+ "required": ["name"]
5388
+ },
5389
+ "network": {
5390
+ "type": "object",
5391
+ "properties": {
5392
+ "mode": {
5393
+ "enum": ["owner", "observer"]
5394
+ },
5395
+ "sync": {
5396
+ "type": "array",
5397
+ "items": {
5398
+ "type": "string",
5399
+ "minLength": 1
5400
+ }
5401
+ },
5402
+ "throttleMs": {
5403
+ "type": "number"
5404
+ }
5405
+ },
5406
+ "additionalProperties": false
5407
+ },
5408
+ "children": {
5409
+ "type": "array",
5410
+ "items": {
5411
+ "$ref": "#/$defs/node"
5412
+ }
5413
+ }
5414
+ },
5415
+ "required": ["name", "type"]
5416
+ },
5326
5417
  "RigidBody2D": {
5327
5418
  "type": "object",
5328
5419
  "x-signals": ["triggerEnter", "triggerExit"],
@@ -94,7 +94,7 @@ meta (name/authors) + humanoid bone count. Decision rules:
94
94
 
95
95
  `ModelInstance3D` props: `model` (`$key` or a direct URL), `targetHeight` (>0 uniformly
96
96
  scales the model to stand that many units tall — composes with the node `scale`, reactive
97
- at runtime; see "Sizing" below), `animation` (see below), `tint` (hex; `""`=off — multiplies into
97
+ at runtime; see "Sizing" below), `animation` (see below), `tint` (any CSS colour — `"#5f8f4a"`, `"chartreuse"`; `""`=off — multiplies into
98
98
  every material to RESKIN one shared GLB into many variants, e.g. base human → green
99
99
  zombie; clones materials per instance so other instances are untouched),
100
100
  `metalness`/`roughness` (0..1 overrides applied to EVERY material; `-1`=keep authored —
@@ -86,7 +86,16 @@ Every catalog entry has a `url` that is directly usable; there are two classes:
86
86
  `copy` does this for you and prints the ready-to-paste JSON — for animated
87
87
  sheets it includes the full `animations` map (idle/move/attack…) derived from
88
88
  the sheet metadata. `Sprite2D.texture` / `AnimatedSprite2D.sheet` take a
89
- `"$assetKey"` ref (the engine hard-fails on a raw URL).
89
+ `"$assetKey"` ref, and the engine hard-fails at LOAD on anything else — a raw
90
+ URL, a bare catalog id, or a `$key` the scene does not declare:
91
+
92
+ ```
93
+ [UNKNOWN_ASSET] 'Coin.texture' names asset '$coinn', which the scene does not
94
+ declare. Declared: [coin, gem]. (at '/Room/Coin')
95
+ ```
96
+
97
+ (It genuinely did not, until 0.61: the check lived in the renderer, so only a
98
+ browser ever reached it and a typo'd ref simply drew nothing.)
90
99
 
91
100
  The scene **composer (incanto-editor)** wires all of this for you: the inspector
92
101
  shows an asset PICKER on every texture/sheet/map prop — browse the built-ins by
@@ -138,9 +138,13 @@ motor.stop(1.2); // …or a slow custom fade
138
138
  } }
139
139
  ```
140
140
 
141
- Methods: **`play()`**, **`stop()`**. Signal: **`finished`** (fires when a
142
- non-looping `src` clip ends — connect it to clean up or chain sounds). Procedural
143
- presets are fire-and-forget one-shots: they do not emit `finished`.
141
+ Methods: **`play()`**, **`stop()`**. Signal: **`finished`** — connect it to clean
142
+ up or chain sounds. It fires when a non-looping `src` clip ends AND when a
143
+ procedural preset does: a preset's length is `attack + sustain + decay`, known
144
+ exactly, so `player.playing` is true for that long and `finished` arrives at the
145
+ end. It is measured on the unscaled clock, because a paused game still hears the
146
+ tail of the hit that paused it, and it fires headlessly too — the length is
147
+ arithmetic, not a device. `stop()` is not a finish and does not emit.
144
148
 
145
149
  ### Autoplay + the browser gesture-unlock
146
150
 
@@ -175,14 +179,14 @@ and models that 404'd:
175
179
 
176
180
  ---
177
181
 
178
- ## 2b. Spatial (3D positional) audio
182
+ ## 2b. Spatial (positional) audio
179
183
 
180
- Set **`spatial: true`** on an `AudioPlayer` in a **3D scene** and the sound pans
181
- (left/right) and attenuates by **distance** — the emitter is the node's world
182
- position, the **listener is the active `Camera3D`** (the one marked `current`).
183
- The 3D adapter feeds the emitter + listener pose to the panner every frame, so
184
- moving the emitter or the camera updates the sound live. Works for BOTH paths
185
- (procedural `preset` and a `src` file).
184
+ Set **`spatial: true`** on an `AudioPlayer` and the sound pans (left/right) and
185
+ attenuates by **distance** — the emitter is the node's world position, the
186
+ **listener is the active camera** (`Camera3D` or `Camera2D`, the one marked
187
+ `current`). The adapter feeds the emitter + listener pose to the panner every
188
+ frame, so moving the emitter or the camera updates the sound live. Works in both
189
+ dimensions and for BOTH paths (procedural `preset` and a `src` file).
186
190
 
187
191
  ```jsonc
188
192
  // Attach under a moving Node3D — it emits from THAT node's world position.
@@ -214,9 +218,13 @@ listener driven by the camera's world position + orientation. The pure
214
218
  distance-gain + pan-sign math (`spatialGain`, `spatialPan`, exported) is what the
215
219
  non-WebAudio paths use, so headless gameplay is unaffected.
216
220
 
217
- > **2D scenes:** spatial is currently **ignored** (no listener is fed) — a 2D
218
- > sound plays non-positionally regardless of `spatial`. Pan a 2D sound yourself
219
- > from the camera-relative x-offset if you need it. Spatial is a 3D feature.
221
+ > **2D scenes** work the same way: the listener is the active `Camera2D` (its
222
+ > world position is the view centre, and its own rotation turns the stereo image
223
+ > with it). The one difference is the UNIT — a 2D scene measures in PIXELS,
224
+ > and `refDistance: 1` / `maxDistance: 50` are metre-shaped defaults that put a
225
+ > sound at its quietest about one sprite away. Set them to your level's scale
226
+ > (`refDistance: 100`, `maxDistance: 900` is a reasonable start); `incanto check`
227
+ > warns when it sees `spatial: true` in a 2D scene with the defaults left alone.
220
228
 
221
229
  > **Headless / verify VM:** spatial is a no-op like all audio — `play()` won't
222
230
  > throw and your gameplay logic is unaffected.
@@ -102,7 +102,7 @@ What behavior code actually calls at runtime — all instance methods, no global
102
102
  | `engine.stop()` / `engine.start()` | pause / resume the loop — stop resets the clock and accumulator, so no banked sim time leaks into the resume |
103
103
  | `engine.step()` | advance exactly ONE fixed step + one update (both dt = the fixed step) — the unit of time for headless tests |
104
104
  | `engine.tick(timestampMs)` | manual frame advance — takes an **absolute** ms timestamp (rAF-style), NOT a dt; the first call after (re)start only primes the clock |
105
- | `engine.setScene(scene)` | swap scenes: the previous root is freed, the input map is cleared and redeclared from the new scene's `input{}`, then `sceneChanged` fires |
105
+ | `engine.setScene(scene)` | swap scenes: the previous root is freed, the input map is cleared and redeclared from the new scene's `input{}`, the clock resets (`time`, `unscaledTime`, and **`timeScale` back to 1** — a level restarted out of a frozen game-over must not boot frozen), then `sceneChanged` fires |
106
106
  | `engine.stats()` | live perf counters `{ fps, frameMs, nodes, running }` — fps/frameMs average the last ~60 REAL `tick` frames (headless `step()` runs report 0), nodes is the current tree size. GPU counters (triangles/draw calls) live on `renderer.stats()` / the merged `game.stats()` |
107
107
 
108
108
  The recurring traps: `duplicateNode` does NOT insert the clone anywhere — a
@@ -138,11 +138,50 @@ declared by their classes. Escape hatch for runtime one-offs: `node.declareSigna
138
138
  ]
139
139
  ```
140
140
  - `handler` must exist on the target NODE or its BEHAVIOR — hard `UNKNOWN_HANDLER`
141
- otherwise (validated at load; node methods take precedence on invocation).
141
+ otherwise, and hard `AMBIGUOUS_HANDLER` when BOTH have it (node methods take
142
+ precedence on invocation, so the script's would never run). Prefix handlers
143
+ with `on` and the question never comes up.
142
144
  - Handlers receive the EMITTED args only (e.g. the other body for `triggerEnter`) — not
143
145
  the emitting node. For per-emitter logic, attach a small behavior to the emitter itself
144
146
  (see the `Pickup` pattern: a coin's own `triggerEnter` → its own `onTaken` → `queueFree`).
145
147
 
148
+ ## Respawn (core node — catch a player who leaves the world)
149
+
150
+ Hang it off the thing it guards. That is the whole feature:
151
+
152
+ ```json
153
+ { "name": "Player", "type": "RigidBody3D", "props": { "…": "…" },
154
+ "children": [
155
+ { "name": "Controller", "type": "CharacterController3D" },
156
+ { "name": "Catch", "type": "Respawn" }
157
+ ] }
158
+ ```
159
+
160
+ With no props it guards its parent, catches it 50 m under the spawn (1000 px in
161
+ 2D — the same line `incanto-playtest` calls `fell`), puts it back where it
162
+ started and zeroes the velocity the fall built up. Works in both dimensions.
163
+
164
+ | Prop | Default | Meaning |
165
+ |---|---|---|
166
+ | `target` | `".."` | who is being caught — the parent, normally |
167
+ | `below` | `null` | the line, in the scene's own down. `null` = auto (see above) |
168
+ | `to` | `[]` | where to put it back. Empty = wherever it started |
169
+ | `resetVelocity` | `true` | drop the speed the fall built up — off and it falls straight back through |
170
+
171
+ Signal `respawned(target, y)`, and a `falls` counter. It deliberately does NOT
172
+ decide what falling COSTS: wire `respawned` to a `ScoreKeeper.loseLife` or a
173
+ `Health.damage` if it should hurt, and leave it alone if it should not.
174
+
175
+ **A node, not a behavior**, because a node holds one behavior and the player's
176
+ is already spoken for.
177
+
178
+ Nothing in the engine did this before 0.62, and five shipped examples proved
179
+ what that cost: with the `plays` rung finally counting defects,
180
+ `basic-3d-sideview` failed 8 seeded runs of 8, `water-lake-3d` 7,
181
+ `water-ocean-3d` 5, `water-pool-3d` 4, `water-river-3d` 1 — every one of them
182
+ the player walking off the terrain and falling forever. One `Respawn` node each,
183
+ no TypeScript, and all five are clean.
184
+
146
185
  ## Timer (core node — never setTimeout in game logic)
147
186
 
148
187
  ```json
@@ -166,7 +205,9 @@ Child of a `CharacterBody2D` (hard error otherwise):
166
205
  - Defaults: `mode 'platformer'`, `maxSpeed 220`, `jumpHeight 64`, `moveAction 'move'`,
167
206
  `jumpAction 'jump'`. Timer defaults: `waitTime 1`, `oneShot false`, `autostart false`.
168
207
  - ⚠️ Connection handler names must not collide with Node API methods (`emit`, `update`,
169
- `on`, `queueFree`, …) — the NODE method wins silently. Prefix handlers with `on`.
208
+ `on`, `queueFree`, …) — the NODE method wins. That is `AMBIGUOUS_HANDLER` at load
209
+ now, not a silent wrong call; `incanto check` warns on the same pair from the
210
+ file alone. Prefix handlers with `on`.
170
211
 
171
212
  Reference: [examples/2d-phaser-sprite-character-gravity](https://github.com/rareboe/Incanto/tree/main/examples/2d-phaser-sprite-character-gravity) — engine nodes + one small PlayerControl behavior
172
213
  (`CoinCounter`, `Pickup`) wired entirely through JSON connections. Verified in Chromium.
@@ -170,10 +170,21 @@ ALWAYS confirm in the browser (`bun run dev`) that the feet touch the surface.
170
170
  | `zoom` | `1` | 2 = world pixels doubled |
171
171
  | `limits` | `[]` | `[minX,minY,maxX,maxY]` — view rect clamped inside |
172
172
  | `current` | `false` | mark exactly one |
173
- Camera `position` is the view CENTER. With no camera, the view shows (0,0)–(w,h).
173
+ Camera `position` is the view CENTER, and it composes with its ancestors like any
174
+ other node — so parenting the camera to the player (the Godot/Phaser idiom) works,
175
+ and so does putting it inside the container that holds the level. `follow` and the
176
+ `FollowCamera` behavior compare WORLD positions at both ends, so the target can live
177
+ under a different parent than the camera. With no camera, the view shows (0,0)–(w,h).
178
+
179
+ (Before 0.62 the view centre was the camera's LOCAL prop. A camera under a player at
180
+ `[1400, 900]` framed the world origin — and `framing`, which composes full world
181
+ matrices, reported `centred [1400, 900] … 1 in view`, certifying a frame the renderer
182
+ never drew.)
174
183
 
175
184
  ### `Label`
176
- `text`, `fontSize 16`, `color '#ffffff'`, `font 'monospace'`, `align 'left'|'center'|'right'`.
185
+ `text`, `fontSize 16`, `color '#ffffff'`, `font 'monospace'`, `align 'left'|'center'|'right'`,
186
+ `opacity 1` (0..1, like `Sprite2D`/`ColorRect2D` — a `Label` can fade now, which is
187
+ what makes `FloatAway` work on one).
177
188
  CanvasTexture-rendered; mutate `label.text` freely (re-rasterizes only on change).
178
189
 
179
190
  ### `UILayer`
@@ -250,14 +261,21 @@ listing the valid set. With a viewport design, UI coordinates are design px.
250
261
  The input map is CLEARED by the swap, which matters for a headless drive: an
251
262
  injected `setActionVector` does not carry into the next level — set it again
252
263
  after the transition, the same way a player's held key is re-read.
264
+
265
+ So is the CLOCK: `time`, `unscaledTime` and `timeScale` all start over. That
266
+ last one is why the game-over recipe below is safe — a swap out of a frozen
267
+ screen boots the next level running, not frozen.
253
268
  - **Game over / restart**: swap to a fresh load of the SAME JSON —
254
269
  `engine.setScene(loadScene(levelJson))`. `loadScene` treats the JSON as
255
270
  read-only (everything it keeps is cloned), so reloading the same imported
256
271
  object yields a clean run; only clone (`structuredClone(levelJson)`) first if
257
272
  your own code mutated that object. (`createGame`'s `scene` option already
258
273
  structuredClones internally.)
259
- - **Pause / resume**: `engine.stop()` halts the loop (clock and accumulator
260
- reset — no banked time on resume); `engine.start()` resumes.
274
+ - **Pause / resume**: `engine.timeScale = 0` is the pause you want — the loop
275
+ keeps running, so menus, banners and the key that closes them stay live (this
276
+ is what `GameFlow.pause()` does). `engine.stop()` halts the loop entirely
277
+ (clock and accumulator reset — no banked time on resume); `engine.start()`
278
+ resumes. Either way a `setScene` puts `timeScale` back to 1.
261
279
  - **Save / load** (a behavior, localStorage):
262
280
  ```ts
263
281
  localStorage.setItem('save', JSON.stringify({ score: this.score }));
@@ -348,6 +366,16 @@ Mark unchanging subtrees (tile decor, backgrounds) `static: true` — the
348
366
  renderer syncs them once and skips them every frame. Flip to `false` to
349
367
  resume live syncing.
350
368
 
369
+ The freeze waits for the node to have something worth freezing: a `Sprite2D` or
370
+ `TileMap2D` whose texture has not decoded yet keeps syncing until it has, and a
371
+ texture that never arrives never freezes at all (`assetErrors()` is what reports
372
+ that one). Before 0.62 the latch fired on frame one regardless — and a
373
+ `TextureLoader` fills `.image` asynchronously while the renderer loads and syncs
374
+ in the same frame, so the state frozen was `visible: false`, permanently. A
375
+ static backdrop was invisible for the life of the game with nothing reporting
376
+ it: the texture loaded fine, `stats().errors` was 0, and `framing` reads props
377
+ rather than pixels and still called the node on-screen.
378
+
351
379
  ## Before you hand it back
352
380
 
353
381
  You cannot see this game. `incanto-verifying-your-game.md` is how you find out
@@ -69,7 +69,7 @@ All 3D nodes extend `Node3D` and therefore have the transform props:
69
69
  |---|---|---|
70
70
  | `position` | `[0,0,0]` | meters; +Y up |
71
71
  | `rotation` | `[0,0,0]` | **degrees**, Euler XYZ |
72
- | `scale` | `[1,1,1]` | |
72
+ | `scale` | `[1,1,1]` | resizes the DRAWN mesh. `Terrain3D`/`Water3D`/`River3D` reject a non-unit scale at load — they keep a CPU twin (`heightAt`, the heightfield collider, the water surface query) built from `size`, so scaling would move the picture and not the ground. Resize those with `size` |
73
73
  | `visible` | `true` | hides the whole subtree's rendering |
74
74
 
75
75
  ### `MeshInstance3D`
@@ -104,7 +104,7 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
104
104
  changed. A prop is rewritten only if its type declares `nodePath: true`, so a
105
105
  text prop that merely reads "Player" is never touched. `+` adds a child of any registered
106
106
  type, `✕` deletes the selection. Selecting the ⚙ scene row edits the header
107
- (dimension, gravity with real inputs, environment/input/assets/multiplayer).
107
+ (dimension, gravity with real inputs, environment/input/assets/multiplayer/strings).
108
108
  - **CONNECTIONS on the selected node** — the inspector's last section shows what
109
109
  the node **emits** and what it **receives**, each row being signal → target →
110
110
  handler with a ✕ to remove and `+ connect` to add. Signals come from the node
@@ -315,6 +315,15 @@ toward `target.position + offset`. Works in 2D and 3D.
315
315
  "script": { "name": "FollowCamera", "props": { "target": "/root/Player", "smoothing": 0.9 } } }
316
316
  ```
317
317
 
318
+ **Not together with `CharacterController3D`.** That controller drives the
319
+ scene's current `Camera3D` itself, in every one of its views — so a
320
+ `FollowCamera` on the same camera is a second writer, and the two fight for the
321
+ position every frame. Measured on a scene with both, target at (10, 0, 0): the
322
+ camera settled at (6.59, 0.22, 1.36), which is neither answer, with no error
323
+ anywhere. Use the controller's own `view` / `camDistance` for a third-person
324
+ camera, and keep `FollowCamera` for the cameras nothing else drives.
325
+ `incanto check` reports the pair.
326
+
318
327
  ## Patrol
319
328
 
320
329
  Walk a node along fixed waypoints at constant `speed` — guards, moving platforms.
@@ -504,6 +513,11 @@ pickups, spinning/pulsing coins. Drives one `axis` of `position`, `rotation`
504
513
  "script": { "name": "Oscillate", "props": { "axis": "z", "amplitude": 180, "frequency": 0.5, "mode": "rotation" } } }
505
514
  ```
506
515
 
516
+ On a physics BODY, `mode: "rotation"` turns the collider too — a spinning blade
517
+ cuts, a swinging gate blocks. (Before 0.62 it turned only the mesh: a body's
518
+ angle was applied once at creation and never followed again, so every spinner
519
+ built this way was decoration.)
520
+
507
521
  ## Buoyancy
508
522
 
509
523
  Float on water. Hangs on a `RigidBody3D` and asks the water how high it is at
@@ -576,7 +590,7 @@ later clones get `EnemyTemplate2`, `EnemyTemplate3`, …)
576
590
  | `prefab` | `""` | node path of the template to clone (required) |
577
591
  | `interval` | `1` | seconds between spawns |
578
592
  | `max` | `0` | max LIVE instances (0 = unlimited) |
579
- | `at` | `[]` | offset added to the spawner position ([x,y(,z)]) |
593
+ | `at` | `[]` | where the clone appears, as an offset from the spawner ([x,y(,z)]) |
580
594
  | `autoStart` | `true` | begin ticking at ready |
581
595
  | `total` | `0` | total LIFETIME spawns (0 = infinite) |
582
596
 
@@ -625,6 +639,15 @@ MOVEMENT by design: **compose** it with `DamageOnContact` (deal damage), `Lifeti
625
639
  `direction` is a vector OR `'forward'` (from the node `rotation`); it's a
626
640
  union-typed prop so its default is `null` (= `'forward'`).
627
641
 
642
+ In 3D, `'forward'` is the engine's ONE facing convention — **+Z**, the same
643
+ `atan2(dx, dz)` basis `CharacterController3D` uses and `incanto-3d-character.md`
644
+ spells out under "the +Z-FORWARD rule". So aim the bullet (or its shooter) with
645
+ that formula and it flies at what you aimed at; do not add 180°. (Before 0.62
646
+ this returned three.js's −Z instead, so every `'forward'` shot left the muzzle
647
+ pointing at the shooter's back — at the right speed, with no error, which reads
648
+ as a gun that simply never hits anything.) In 2D `rotation` is a scalar and
649
+ `'forward'` is +x turned clockwise by it, unchanged.
650
+
628
651
  | Prop | Default | Meaning |
629
652
  |---|---|---|
630
653
  | `speed` | `300` | units per second along `direction` |
@@ -822,7 +845,9 @@ import { CameraShake, Cooldown, hitStop, screenFlash } from 'incanto/gameplay';
822
845
  - **hitStop(engine, seconds?)** — freezes `engine.timeScale` for REAL
823
846
  seconds then restores; stacked calls extend. Sells melee impacts. It measures
824
847
  those seconds on `engine.unscaledTime`, so it thaws in a headless session
825
- exactly as it does in a browser.
848
+ exactly as it does in a browser, and a `setScene` under a live freeze drops it
849
+ (the swap resets that clock, and the killing blow — hit-stop, then restart —
850
+ is exactly when the two meet).
826
851
  - **engine.timeScale** — 1 realtime, 0.5 slow motion, 0 pause: scales
827
852
  variable AND fixed updates together (physics, timers, behaviors) — and it
828
853
  means the same thing headless, so a pause menu and a slow-motion finish can be
@@ -848,6 +873,13 @@ puff, a 2D `Label`. It reads the start position ONCE, so the ease does not
848
873
  compound, and it frees the node a frame after it is invisible rather than on the
849
874
  frame it gets there (which reads as a flicker).
850
875
 
876
+ **`opacity` is not optional.** A node without one still rises and still frees
877
+ itself, and skips the fade entirely — the number pops out at full brightness,
878
+ which is the thing this behavior exists to prevent. It says so at ready, naming
879
+ the node, rather than leaving you to spot a missing fade by eye. (The 2D `Label`
880
+ in the list above is the reason: it had no `opacity` prop until 0.57, so the one
881
+ 2D node the doc named was the one it could not fade.)
882
+
851
883
  ## Did the effect fire?
852
884
 
853
885
  Sound has `engine.audio`; vision has **`engine.effects`**, and the question is
@@ -933,6 +965,11 @@ Terminal states ignore further transitions; the restart action (or
933
965
  physics, input. Listen to the `flowChanged(state)` signal for custom UI.
934
966
  `restartScene(engine)` is exported standalone.
935
967
 
968
+ **Any scene swap thaws.** The freeze lives on the engine, not on the flow, so
969
+ leaving a game-over screen by your own route — `engine.setScene(loadScene(next))`
970
+ from a menu button — puts `timeScale` back to 1 as surely as `flow.restart()`
971
+ does. You cannot restart into a frozen level.
972
+
936
973
  Multi-scene games: `flow.goToScene(nextSceneJson, { fadeSeconds: 0.4 })`
937
974
  fades to black, swaps, fades back (headless = instant). Title → level →
938
975
  next level is three JSON files and this one call.
@@ -225,9 +225,11 @@ slot, the slot takes it. **Who owns the item is your game's business** — these
225
225
  report the GESTURE, not a model, so an inventory that stacks, swaps or refuses is
226
226
  your `connections` and a behavior, not a prop nobody could have guessed.
227
227
 
228
- A drop on a CHILD of a slot counts as a drop on the slot (the cursor lands on the
229
- icon inside it, which is the normal case), a drop on nothing cancels, and a
230
- widget never drops onto itself.
228
+ A drop on a CHILD of a slot counts as a drop on the slot — the cursor lands on
229
+ the icon inside it, which is the normal case, and the icon is itself a widget.
230
+ A drop on nothing cancels, and so does putting a thing back into the slot it
231
+ came from: that is not a move, and firing `dropped` for it would make every
232
+ mis-grab look like a transfer.
231
233
 
232
234
 
233
235
 
@@ -99,6 +99,13 @@ banner.show(this.engine.t('hud.wave', { n: this.wave }));
99
99
  slot; a scene-JSON prop has no params to fill it from. A slot with no matching
100
100
  param is left alone rather than blanked.
101
101
 
102
+ ### Authoring the table
103
+
104
+ The scene header's **advanced** section has a `strings` field — the same
105
+ raw-JSON editor `input` and `multiplayer` use. Until 0.61 it was the one of
106
+ those three the panel did not show, so a localized game could be read by the
107
+ editor and never authored in it.
108
+
102
109
  ## Checking a translation without a browser
103
110
 
104
111
  No headless check could see a translated string: a capture printed
@@ -482,6 +482,7 @@ Signals: `movementStateChanged(state)`
482
482
  | `color` | `"#ffffff"` | string |
483
483
  | `font` | `"monospace"` | string |
484
484
  | `align` | `"left"` | string |
485
+ | `opacity` | `1` | number |
485
486
 
486
487
  ## `Label3D` — `incanto`
487
488
 
@@ -705,6 +706,17 @@ Signals: `finished`
705
706
 
706
707
  Signals: `finished`
707
708
 
709
+ ## `Respawn` — `incanto`
710
+
711
+ | Prop | Default | Kind |
712
+ |---|---|---|
713
+ | `target` | `".."` | node path |
714
+ | `below` | `null` | null |
715
+ | `to` | `[]` | array |
716
+ | `resetVelocity` | `true` | boolean |
717
+
718
+ Signals: `respawned`
719
+
708
720
  ## `RigidBody2D` — `incanto/2d`
709
721
 
710
722
  | Prop | Default | Kind |
@@ -50,6 +50,16 @@ the corners. Give the body its mesh as a CHILD and let the shape follow:
50
50
  "props": { "mesh": "box", "size": [14, 0.22, 2.2] } } ] }
51
51
  ```
52
52
 
53
+ The child's OWN `position`, `rotation` and `scale` are part of the shape: raise the plank on
54
+ the child instead of the body and the collider goes up with it; scale the crate 4× and it is
55
+ solid at 4×. (It was not before 0.62 — the child transform was dropped and the collider sat
56
+ at the body's origin at the authored size, so you walked through the deck you could see and
57
+ landed on an invisible slab at ground level. Measured: a plank drawn with its top at y=3.2
58
+ had its surface at y=0.2.) `collider.offset` stacks on top of that.
59
+
60
+ A ball, a capsule and a cylinder have ONE radius between them, so a child scaled unevenly in
61
+ x and z has no exact shape — the widest of the two is used and the engine says so by name.
62
+
53
63
  Bodies ignore an ANCESTOR's rotation, so a yawed structure puts each body at top level with
54
64
  its own `rotation` — the child mesh then turns with it, visual and collider together.
55
65
 
@@ -143,7 +153,13 @@ engine.input.isPressed('jump'); // held
143
153
  engine.input.justPressed('jump'); // one-frame edge (settled per tick)
144
154
  engine.input.getVector('move'); // normalized {x, y}, y-down (up = -y)
145
155
  ```
146
- Keys are `KeyboardEvent.code` strings (`KeyW`, `Space`, `ArrowLeft`). Unknown actions and
156
+ Keys are `KeyboardEvent.code` strings (`KeyW`, `Space`, `ArrowLeft`) — plus `Mouse0..4`
157
+ and `Pad0..16`, which live in the same space. **A value outside that set is a load
158
+ error** naming the one you probably meant (`'W' → "KeyW"`, `'space' → "Space"`,
159
+ `'Shift' → "ShiftLeft" or "ShiftRight"`): the map is looked up by exact string, so a
160
+ misspelled code binds to nothing and the player silently does not have that control.
161
+ An EMPTY key list is still fine — `{"keys": [], "touch": "button"}` is a touch-only
162
+ control. Unknown actions and
147
163
  wrong-kind queries (getVector on a button) are hard errors listing valid names.
148
164
  Action declarations RESET on every `setScene` (no keybind bleed between scenes).
149
165
 
@@ -239,7 +255,12 @@ Injected state combines with key state (vectors clamped to unit length).
239
255
  - Pickups: `Area2D` in a group + `triggerEnter` → check `other.isInGroup('player')` →
240
256
  `queueFree()` — wire it via JSON `connections` to a behavior method (the `Pickup`
241
257
  pattern in `incanto-behaviors-and-scripts.md`), or imperatively with `node.on(...)`.
242
- - Teleport: just write `node.position` — the body follows. Launch: write `linearVelocity`.
258
+ - Teleport: just write `node.position` — the body follows. **`node.rotation` follows
259
+ too**: turn a static or kinematic body at runtime and its COLLIDER turns with it, so
260
+ a spinning blade cuts and a swinging gate blocks. (It did not before 0.62 — the
261
+ angle was applied once at creation, the mesh turned and the collider stayed put.)
262
+ A free dynamic body's rotation belongs to the solver and is left alone.
263
+ Launch: write `linearVelocity`.
243
264
  - Reference: [examples/2d-phaser-sprite-character-gravity](https://github.com/rareboe/Incanto/tree/main/examples/2d-phaser-sprite-character-gravity) — gravity, jump, attack lockout, custom
244
265
  `Player` node type. Verified in Chromium end-to-end.
245
266
 
@@ -299,7 +299,11 @@ that do exist at the failing spot).
299
299
  - `signal` must be DECLARED on the from node (the class's or its behavior's
300
300
  `static signals`) — validated at load → `UNKNOWN_SIGNAL` otherwise.
301
301
  - `handler` must be a method on the target node OR its behavior (script) — both are
302
- validated hard at load. Otherwise → `UNKNOWN_HANDLER`. Script names themselves must be
302
+ validated hard at load. Otherwise → `UNKNOWN_HANDLER`. On **both**, it is
303
+ `AMBIGUOUS_HANDLER`: the node's method wins, so the script's would never run
304
+ and nothing would say so. A core node answers to 62 public methods before any
305
+ adapter adds more (`stop` `play` `show` `clear` `say` `start` `free` …), which
306
+ is why the collision is easy to write — rename the script's method. Script names themselves must be
303
307
  registered (`registerBehavior`) before `loadScene` → `UNKNOWN_BEHAVIOR` otherwise.
304
308
  - Unresolvable `from`/`to` → `DANGLING_CONNECTION` at load. Renaming a node breaks its
305
309
  connections **loudly** — update paths in the same edit.
@@ -336,6 +340,7 @@ that do exist at the failing spot).
336
340
  | `DUPLICATE_UNIQUE_NAME` | `%Name` matches ≥2 nodes | rename one, or use an explicit path |
337
341
  | `DANGLING_CONNECTION` | connection `from`/`to` unresolvable | fix the path after renames |
338
342
  | `UNKNOWN_HANDLER` | handler missing on the node AND its behavior | fix the method name |
343
+ | `AMBIGUOUS_HANDLER` | handler is a method on the node AND on its behavior — the node's wins silently | rename the behavior's method |
339
344
  | `UNKNOWN_SIGNAL` | connection (or emit/on) names a signal the from node never declares | use a declared signal, or declare it (`static signals` / `declareSignal`) |
340
345
  | `UNKNOWN_BEHAVIOR` | `script.name` not registered | `registerBehavior(name, Class)` before `loadScene` |
341
346
  | `UNRESOLVED_INSTANCE` | no resolver, unknown path, or instance cycle | provide/fix `resolveScene`, break the cycle |
@@ -32,6 +32,7 @@ failures — but `framing` and `assetErrors()` you have to ASK for.
32
32
  $ bunx incanto verify # finds your scene AND your behaviours
33
33
  · behaviours: src/behaviors.ts (found, not named)
34
34
  ✓ loads — the scene is legal and its assets resolve
35
+ ! /Game/Chest: script Chest — target '%Ke' matches nothing
35
36
  ? plays — 8 runs played without reaching a win (4 lost, 4 ran out the clock)
36
37
  ✓ feels — 5 of 7 fired — silent: /Game/Boss/Roar, /Game/Boss/Boom
37
38
  · agrees — not run — this scene has no `multiplayer` header
@@ -67,11 +68,40 @@ encodes so you do not have to remember them:
67
68
 
68
69
  - a **failed** rung makes the ones above it meaningless, so only the first is
69
70
  worth reading — the rest are marked `·` skipped;
71
+ - a rung that PASSED can still have something to say. Anything its own tool
72
+ warned about is printed under it with a `!` — `loads` runs `incanto-check`,
73
+ and the ladder used to read only whether it passed and drop the warnings, so a
74
+ scene the check describes as "It will render black" came back as
75
+ `✓ loads — the scene is legal and its assets resolve`;
70
76
  - an **unmeasured** rung (`?`) is not a failure. "No dev server" means the
71
77
  question was never asked; treating that as a broken game sends you editing a
72
78
  scene that is fine;
73
79
  - a scene that declares **no win** is not a scene that cannot be won. A
74
- walkabout has no end, and that is reported as unmeasured, not failed.
80
+ walkabout has no end, and that is reported as unmeasured, not failed;
81
+ - but a **defect** fails the rung whatever the scene declares. `error` (a
82
+ behaviour threw) and `fell` (the player left the world) are counted BEFORE
83
+ anything else is decided:
84
+
85
+ ```
86
+ ✗ plays — 8 of 8 runs ended in a defect (8 fell out of the world)
87
+ next: the player left the world with nothing catching them — give the level a
88
+ floor, walls, or a respawn
89
+ ```
90
+
91
+ The routed answer to that last one is a `Respawn` node under the player
92
+ (`incanto-behaviors-and-scripts.md`) — one line of JSON, no per-frame `y`
93
+ check in your own code.
94
+
95
+ This used to be the sentence `8 runs played without error` — printed for a
96
+ scene with no declared win no matter what happened in it, including 8/8
97
+ `fell`, and exit 0. The child `incanto-playtest` had just printed
98
+ `✗ fell in 8/8`; the parent said the opposite of it.
99
+
100
+ `stuck` — the player went nowhere — is the third defect and the one that needs
101
+ a fair chance before it counts. It is only a failure when there WAS someone to
102
+ move and your behaviours were loaded. A scene with no character controller, no
103
+ `player` group and no node named `Player` reports `nothing here is drivable`,
104
+ and a run without `--behaviors` reports that instead: neither is a broken game.
75
105
 
76
106
  `feels` is the sound-and-effects rung: it lists what the scene DECLARES against
77
107
  what actually fired during the playtest. A game whose feedback is wired but