incanto 0.69.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1088 -882
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.js +0 -10958
@@ -34,7 +34,7 @@ $ bunx incanto verify # finds your scene AND your behaviou
34
34
  ✓ loads — the scene is legal and its assets resolve
35
35
  ! /Game/Chest: script Chest — target '%Ke' matches nothing
36
36
  ? plays — 8 runs played without reaching a win (4 lost, 4 ran out the clock)
37
- feels — 5 of 7 fired — silent: /Game/Boss/Roar, /Game/Boss/Boom
37
+ ? feels — 5 of 7 fired — silent: /Game/Boss/Roar, /Game/Boss/Boom
38
38
  · agrees — not run — this scene has no `multiplayer` header
39
39
  ? draws — the dev server is running on :5173, but no page answered
40
40
 
@@ -106,8 +106,17 @@ and a run without `--behaviors` reports that instead: neither is a broken game.
106
106
  `feels` is the sound-and-effects rung: it lists what the scene DECLARES against
107
107
  what actually fired during the playtest. A game whose feedback is wired but
108
108
  never triggered plays perfectly and feels dead, and every other rung calls it
109
- healthy. Nothing firing at all is a failure; some nodes staying silent is
110
- reported by name, because the bot may simply never have reached the boss.
109
+ healthy. Nothing firing at all is a FAILURE. Some nodes staying silent is
110
+ `unmeasured` (`?`) with the names listed a silent emitter is either a broken
111
+ wire or a moment random play never reached, and the two look identical from
112
+ here. Reach it in a scripted run and this rung can tell you which. It ticks
113
+ (`✓`) only when every declared emitter fired.
114
+
115
+ `says` reads the running page's own log. Errors and failed assets fail it;
116
+ WARNINGS are carried under it with a `!` and it still passes — a camera whose
117
+ `follow` points at a node with no position, a `DayNight` with no sky to move, a
118
+ body with no collider. Those leave a game running and wrong, and they are the
119
+ lines worth reading before anything else on this list.
111
120
 
112
121
  Exit 1 only when a rung actually failed. `--behaviors src/behaviors.ts` matters:
113
122
  without it your game logic is stubbed and only the structure is played.
@@ -221,6 +230,22 @@ console.log(result.describe()); // run report + final scene, one line per
221
230
  if (!result.ok) process.exit(1); // failures carry atMs + label + message
222
231
  ```
223
232
 
233
+ **An assert fails on `false`, on a non-empty STRING, or on a throw.** The
234
+ string is used as the message, which is how a harness explains itself:
235
+
236
+ ```ts
237
+ assert: (ctx) => {
238
+ const lives = (ctx.getNode('Score').behavior as never as { lives: number }).lives;
239
+ if (lives <= 0) return `the keep fell — ${lives} lives`; // the failure, in words
240
+ return null; // nothing to say
241
+ }
242
+ ```
243
+
244
+ `true`, `null`, `undefined` and `''` are passes. Anything else — a number, an
245
+ object — is recorded as a failure naming the contract, because a check that
246
+ cannot fail is not a check. (It used to read only `false` and a throw, so every
247
+ assert in the shape above passed by not being `false`.)
248
+
224
249
  Run it with `bunx tsx verify.ts` (or inside a vitest test). Physics enables
225
250
  automatically from the scene's `dimension`. `result.snapshots` holds periodic
226
251
  captures; `result.logs` holds everything behaviors logged via `this.log`.
@@ -232,6 +257,84 @@ into the process-global registries (hot-replacing same names). In a test
232
257
  suite, isolate with `clearRegistry()`/`clearBehaviors()` between files if
233
258
  you register conflicting classes.
234
259
 
260
+ ### Clicking, for a game the mouse plays
261
+
262
+ `press`/`release`/`vector`/`key` are all ACTIONS, and `Clickable` reads the mouse
263
+ BUTTON — so a match-3, a tower defense, a card game, a point-and-click, an RTS or
264
+ a flip puzzle needs one more verb:
265
+
266
+ ```ts
267
+ steps: [
268
+ { atMs: 100, click: 'Board/Tile3' }, // by node path — the usual way
269
+ { atMs: 400, click: [300, 100] }, // or a raw point, in the scene's units
270
+ ]
271
+ ```
272
+
273
+ A click is a press and a release on two DIFFERENT frames — `clicked` fires on
274
+ release over the node the press started on — and that is handled for you. So is
275
+ the picker: headless there is no renderer and therefore no raycast, so a
276
+ **geometric** one is installed for the run, answering from the same world boxes
277
+ `framing()` measures. A game that installs a picker of its own keeps it — its
278
+ answer always wins.
279
+
280
+ 2D coordinates are world pixels (the space every 2D `position` in the file is
281
+ already written in); 3D casts a ray from the scene's current camera. A `click`
282
+ naming a path that resolves to nothing is a FAILURE, not a click into space.
283
+
284
+ `geometricPicker(scene)` from `incanto/test` is the same thing on its own, for a
285
+ harness that wants to ask "what is under this point" directly.
286
+
287
+ **A `click` naming a HUD widget PRESSES it** — `click: 'Hud/Shop/Buy'` is the
288
+ whole verb for a menu, a shop, a settings screen, an idle game or a card game.
289
+ There is no cursor involved: a widget lives in the DOM overlay, not the world,
290
+ so the click goes to what a MOUSE does to that widget — a button presses, a
291
+ toggle flips, a select advances, and a `UiDialogue` ADVANCES THE LINE without
292
+ answering a waiting choice, exactly as clicking the box does in a browser
293
+ (`advance()` refuses to skip choices). To answer one, do what a player does and
294
+ press the choice, or call `talk.choose(i)` from a `do:` step. Enter and the
295
+ pad's A are a different verb and DO answer.
296
+ A widget that is hidden — or that sits under a hidden `UiPanel` or a hidden
297
+ `HudLayer` — is a FAILURE naming it, because a player could not have pressed it
298
+ either.
299
+
300
+ **A drag needs the two edges apart**, and `click` puts them at one point:
301
+
302
+ ```ts
303
+ steps: [
304
+ { atMs: 100, at: [400, 300], mouse: 'down' }, // press, here
305
+ { atMs: 200, at: [300, 380] }, // and move while held
306
+ { atMs: 300, mouse: 'up' }, // let go, there
307
+ ]
308
+ ```
309
+
310
+ `at` on its own is a HOVER — the state a game shows for a cursor that has not
311
+ clicked yet. `button` picks a different one (0 left · 1 middle · 2 right) for
312
+ `mouse` and for `click` alike. Written with `click`, a drag-and-launch game
313
+ launches with a vector of `from - to` where both are the same point: **it fires
314
+ every shot and every shot goes nowhere**, and the run reports fine.
315
+
316
+ The aiming side is on the engine, not the renderer: a behaviour reads
317
+ `this.engine.pointerWorld()` (and `toWorld`/`toScreen`), and a `click` or `at`
318
+ step installs a geometric one for the run alongside the picker.
319
+
320
+ ### Asking the game a question
321
+
322
+ An assert needs to READ something, and the readable thing is often neither a
323
+ prop nor a method. Every node type's **`Reads:`** line in
324
+ `incanto-node-reference.md` lists its read-only accessors — the ones that exist
325
+ for exactly this:
326
+
327
+ ```ts
328
+ (ctx.getNode('Hud/Banner') as never as { showing: string }).showing // '' when idle
329
+ (ctx.getNode('Talk') as never as { active: boolean }).active // mid-dialogue?
330
+ (ctx.getNode('Gun').behavior as never as { liveCount: number }).liveCount
331
+ (ctx.getNode('Player').behavior as never as { isDead: boolean }).isDead
332
+ ```
333
+
334
+ `banner.text` does not exist and reads as `undefined`, which an assert will
335
+ happily compare against and pass. Look the accessor up rather than guessing a
336
+ prop name.
337
+
235
338
  ## 4. Read the scene like a screenshot
236
339
 
237
340
  ```ts
@@ -300,19 +403,27 @@ from geometry alone — no renderer, no canvas, no browser:
300
403
 
301
404
  ```
302
405
  > framing
303
- camera /World/Player/Cam at [0, 2, 8] looking [0, -0.2, -0.97] fov 60
406
+ camera /World/Player/Cam at [0, 2, 8] looking [0, -0.2, -0.97] up [0, 1, 0] fov 60
304
407
  lit by: /World/Sun (DirectionalLight3D), environment.sky
305
- 3 on screen, 1 off screen, 2 behind the camera
408
+ 3 on screen, 1 off screen, 2 behind the camera, 1 hidden (visible: false)
306
409
  onScreen /World/Island (MeshInstance3D) [0, 0, 0] screen [0.02, -0.11] 8.2m
307
410
  behind /World/Chest (ModelInstance3D) [0, 0, 40] 32m
308
411
  offscreen /World/Gem (MeshInstance3D) [80, 0, 0] screen [4.4, 0] 80.1m
412
+ hidden /World/Boss (ModelInstance3D) [0, 0, -12] 20.1m
309
413
  overlap /World/Platform ∩ /World/Gem
310
414
  ```
311
415
 
312
- Read it for the three visual failures that look identical from a node list:
416
+ Read it for the five visual failures that look identical from a node list:
313
417
 
314
418
  - **`behind`** — the thing is fine, the camera has its back to it. Indistinguishable
315
419
  from "my model did not load" until you can see this line.
420
+ - **`hidden`** — `visible: false` on the node or on any ancestor. The renderer skips
421
+ the whole subtree, so it is NOT in view; a screen drawing nothing used to report
422
+ every one of them as in view. `NOTHING IS DRAWN` when that is all of them.
423
+ - **`up`** — which way is up on screen. Roll changes NOTHING else, so a camera rolled
424
+ 180° prints the same `at` and the same `looking` as an upright one; the report
425
+ says `is UPSIDE DOWN` when `up.y` goes negative (a top-down camera measures
426
+ `up.y = 0` and is left alone).
316
427
  - **`lit by: NOTHING`** — the scene renders black. No error, no log, nothing wrong
317
428
  with any node.
318
429
  - **`overlap`** — two declared colliders intersect: the gem is inside the platform.
@@ -322,6 +433,13 @@ Also `incanto-play`'s `framing` command, and `describeFraming(scene)` /
322
433
  as on screen when its `size` box (or collider) is, not only its origin — so a
323
434
  200 m island whose origin sits off to the left is correctly `onScreen`.
324
435
 
436
+ **HUD widgets are counted separately** (`report.widgets`), because asking
437
+ whether the camera can see one is a category error — and because leaving them
438
+ out meant a shop screen made entirely of widgets and an empty scene file both
439
+ read `0/0 on screen`. A scene with no drawable node AND no widget now says
440
+ `nothing on screen`, and the gate treats it as broken: a scene that draws
441
+ nothing looks exactly like one that failed to load.
442
+
325
443
  **A 2D scene gets a 2D report**, in pixels: the `viewport.design` window centred
326
444
  on the current `Camera2D` and CLAMPED by its `limits`, which is the view the
327
445
  renderer actually draws. Nothing is ever `behind` a 2D camera and nothing is
@@ -334,7 +452,7 @@ the instant a frame composites, so `canvas.toDataURL()` and every screenshot
334
452
  built on it come back BLANK without it:
335
453
 
336
454
  ```ts
337
- createGame3D({ ..., preserveDrawingBuffer: true });
455
+ createGame3D({ ..., preserveDrawingBuffer: true }); // createGame2D too
338
456
  // or in the scene: "environment": { "rendering": { "preserveDrawingBuffer": true } }
339
457
  ```
340
458
 
@@ -520,6 +638,44 @@ The same words `incanto-play` takes — `press`/`release ACTION`, `vector ACTION
520
638
  newlines. `--do-file` reads a longer plan from a file. One vocabulary for the
521
639
  headless gamepad and the live one.
522
640
 
641
+ **Pointing at things**, for the games that are played that way:
642
+
643
+ ```
644
+ $ bunx incanto-frame --do "at %Mole; click; step 300" --out whacked.png
645
+ ```
646
+
647
+ `at X Y` puts the cursor on a canvas pixel and `at <node path>` puts it wherever
648
+ that node is ON SCREEN — the page knows, and you cannot compute it from out
649
+ here. **A `click` before anything has aimed is refused**, because it would land
650
+ wherever the cursor was left, and a plan whose `at` failed would otherwise
651
+ report the aim false and the click true. Headless, `click` also answers with
652
+ what it HIT (`"hit": "/Game/Field/Hole11/Mole"`, or `null`) — the reason to aim
653
+ is to hit something, and from outside the process there is no way to ask. **`incanto-play` takes the same two words**, headless, installing a
654
+ geometric picker for them (there is no renderer to raycast with):
655
+
656
+ ```
657
+ $ printf 'step 700\nat Field/Hole12/Mole\nclick\ndescribe\nquit\n' \
658
+ | bunx incanto-play src/game.scene.json --behaviors src/behaviors.ts
659
+ {"ok":true,"cmd":"at","at":[560,291]}
660
+ {"ok":true,"cmd":"click","t":733}
661
+ … /Game/Hud/Score UiText text="Moles 1 / 12"
662
+ ``` `click` (optionally `left`/`middle`/`right`) presses and releases where the
663
+ cursor is, with a frame in between so the game gets to show that it is being
664
+ pressed.
665
+
666
+ **A drag is `mouse` — press, move, let go**, because `click` is both edges at
667
+ one point and a slingshot, a selection box, a swipe and an item carried between
668
+ inventory slots are all "press here, move there, let go":
669
+
670
+ ```
671
+ $ bunx incanto-frame --do "at 400 300; mouse left down; at 300 380; mouse left up; step 400"
672
+ ```
673
+
674
+ `mouse` takes the same three words `click` does, and the same `0|1|2` the
675
+ `incanto-play` REPL takes. The language used to have only `pointer DX DY`, which is a delta —
676
+ the mouse-LOOK question — so an agent driving its own match-3, tower defense,
677
+ card game or point-and-click could reach no state at all.
678
+
523
679
  `step` waits on the REAL clock: this is the running game, with its own loop, its
524
680
  own physics and its own animation, and stepping it by hand would fight all three.
525
681
 
@@ -529,6 +685,17 @@ then land on a game that never advances: the capture comes back UNCHANGED and
529
685
  looks perfectly healthy. `drove 0 frames` is refused with an error rather than
530
686
  handed over as a picture.
531
687
 
688
+ **A PLAIN capture works on a hidden page.** Only a DRIVE needs the window in
689
+ front. `captureFrame` waits ~120 ms for the game loop and then draws a frame
690
+ itself, exactly because a hidden tab gets none — so the pixels are current, the
691
+ world is simply paused. Measured, hiding every node from the console and
692
+ capturing again: 2D `luminance 0.09 → 0.00 BLACK SCREEN`, 3D `0.64 → 0.96,
693
+ subject 44.3% → 22.0%`.
694
+
695
+ That is the whole point for an agent: **your terminal is in front and the game
696
+ is behind it, and `draws` still answers.** The report says the world is paused,
697
+ and the `draws` rung carries that as a note under a pass.
698
+
532
699
  #### Look at it
533
700
 
534
701
  ```
@@ -779,6 +946,32 @@ rows until the orange box lands on the thing you're hunting.
779
946
  inside a `static` subtree, HUD widgets outside a HudLayer. Programmatic:
780
947
  `auditScene(json)` from `incanto` or `incanto/test` returns the warnings.
781
948
 
949
+ **Including a HUD line that names the wrong key.** `tps-3d`'s pause menu read
950
+ `Esc to resume · R to restart` for exactly three hours after `restart` was moved
951
+ off `KeyR` — the binding moved and the sentence about it did not, which is the
952
+ whole failure mode of any fact written down twice. The check is narrow on
953
+ purpose: it fires only when a line names both a key it recognises AND a word
954
+ that is a DECLARED ACTION in the same scene, so "Esc to resume" says nothing
955
+ (there is no `resume` action) and prose is left alone.
956
+
957
+ **Including one key bound to two actions.** `tps-3d` shipped `reload` and
958
+ `restart` both on `KeyR`: pressing R reloaded, and `restart` — which nothing
959
+ read — looked broken. Two actions on one key are legal, sometimes deliberate,
960
+ so this warns rather than refuses; the sibling question ("does anything read
961
+ this action at all?") is `input.unreadActions()`, in `incanto-playtesting.md`.
962
+
963
+ **Including a `Clickable` nobody listens to.** It produces `clicked`, `hovered`
964
+ and `unhovered` and nothing else, so attaching it and wiring none of them costs
965
+ a pick every frame and can never do anything — the likeliest mistake a mouse
966
+ game makes, and one with no error anywhere. A board of sixteen unwired tiles
967
+ used to print `1/1 scene(s) valid`.
968
+
969
+ ```
970
+ warn: Game/A: Clickable produces nothing but 'clicked'/'hovered'/'unhovered',
971
+ and no connection listens to any of them — so it can never do anything.
972
+ Wire one, or listen in code with `node.on('clicked', …)`.
973
+ ```
974
+
782
975
  **Including a node path that points at nothing.** `Chase.target`,
783
976
  `Camera2D.follow`, `Spawner.prefab`, `Joint3D.target`, `skinPath`, `terrain` —
784
977
  these hold a path to another node, and a wrong one is SILENT: a connection that
@@ -793,8 +986,12 @@ warn: World/Cam: follow — '/Level/Player' starts at 'Level', but the scene
793
986
  ```
794
987
 
795
988
  An empty value is never reported — `""` is the default of most of these and
796
- means "not set". A behavior of YOUR OWN is not reported either: the checker
797
- never loads your TypeScript, so it cannot know which of its props are paths.
989
+ means "not set" (and `getNodeOrNull("")` returns null, so the guard you would
990
+ write for it is unnecessary). A behavior of YOUR OWN is reported too, once you
991
+ hand the checker the file that registers it: `incanto-check src --behaviors
992
+ src/behaviors.ts`. Without the flag it has no schema for your behaviours and
993
+ cannot know which of their props are paths — so pass it, the way every
994
+ scaffolded project's `check` script does.
798
995
 
799
996
  ## Deterministic replay (record once, regression-test forever)
800
997
 
@@ -811,7 +1008,7 @@ save.set('replays/level1', rec.stop());
811
1008
 
812
1009
  // in a headless test:
813
1010
  const engine = new Engine({ seed: SAME_SEED });
814
- engine.setScene(loadScene(sceneJson));
1011
+ engine.setScene(loadScene(sceneJson, { engine }));
815
1012
  replay(engine, recording, { onTick(i) { /* mid-run asserts */ } });
816
1013
  expect(player.position).toEqual(expected); // exact, not approximate
817
1014
  ```
@@ -83,6 +83,34 @@ null when the ray misses (looking at the sky). `pick(sx, sy)` gives you the NODE
83
83
  under a pixel — a different question, and the only one 3D could answer before
84
84
  0.66.
85
85
 
86
+ **Inside a Behavior there is no renderer**, so the same four questions are on
87
+ the engine, installed by whichever renderer is running and cleared when it is
88
+ disposed:
89
+
90
+ ```ts
91
+ this.engine.pointerWorld() // where the cursor is → number[] | null
92
+ this.engine.toWorld(sx, sy) // any screen point → [x, y] | [x, y, z]
93
+ this.engine.toScreen(node.position) // → { x, y, behind } | null
94
+ this.engine.screenRay(sx, sy) // 3D only → { origin, dir } for castRay
95
+ this.engine.pickAt(sx, sy) // which node (cached per frame)
96
+ ```
97
+
98
+ The answer is the scene's own shape — `[x, y]` in 2D, `[x, y, z]` on the ground
99
+ in 3D — so it goes straight back into a `position`. All are null with no
100
+ renderer; a `runScript` `click` step (and `incanto-play`'s `at`) installs
101
+ geometric `toWorld`/`toScreen`/`screenRay` for the run, so a game that AIMS is
102
+ testable headless and not only one that clicks.
103
+
104
+ **On uneven ground, cast the ray** — `toWorld` lands on a plane, which is the
105
+ wrong answer under a hill or a crate:
106
+
107
+ ```ts
108
+ const at = this.engine.input.pointerPosition();
109
+ const ray = at && this.engine.screenRay?.(at.x, at.y);
110
+ const hit = ray && this.engine.physics?.castRay(ray.origin, ray.dir, 100);
111
+ if (hit) place(hit.point); // WHERE it landed, not how far
112
+ ```
113
+
86
114
  Perf note: `useNodeProp` deep-compares per frame — subscribe to LEAF values
87
115
  (`'UI/Score', 'text'`), not big objects like a whole `animations` map.
88
116
 
@@ -40,7 +40,9 @@ the vite config and the verify harness.
40
40
 
41
41
  Pick by shape, not by subject: `tps-3d` (third-person combat), `platformer-2d`,
42
42
  `star-survivor` (top-down survivor), `village-quest-3d` (quest/NPC),
43
- `beacon-isle-3d` (open world).
43
+ `beacon-isle-3d` (open world), `molehill-2d` (**played with the MOUSE** — no
44
+ character, no keyboard: the shape a match-3, a tower defense, a card game, a
45
+ point-and-click or an RTS starts from).
44
46
 
45
47
  ---
46
48
 
@@ -6,6 +6,7 @@
6
6
  * (quest, enemies, HUD) — regenerate the world without touching the game.
7
7
  */
8
8
 
9
+ import { newUid } from 'incanto';
9
10
  import { buildTerrainNav, Terrain3D } from 'incanto/3d';
10
11
  import { generateTerrain } from 'incanto/env';
11
12
 
@@ -145,6 +146,8 @@ dressing.push({
145
146
  name: 'ShoreRocks',
146
147
  type: 'InstancedMesh3D',
147
148
  props: {
149
+ // Sunk into the slope on purpose — see the note by the player's Catch.
150
+ snapToGround: false,
148
151
  mesh: 'gem',
149
152
  size: [0.6, 0.45, 0.6],
150
153
  material: { color: '#7e8590', roughness: 0.95, flatShading: true },
@@ -166,7 +169,7 @@ const ward = (i: number, [x, y, z]: [number, number, number]): unknown => ({
166
169
  name: `Ward${i}`,
167
170
  type: 'Node3D',
168
171
  groups: ['ward'],
169
- props: { position: [round2(x), round2(y), round2(z)] },
172
+ props: { position: [round2(x), round2(y), round2(z)], snapToGround: false },
170
173
  children: [
171
174
  {
172
175
  name: 'Plinth',
@@ -228,7 +231,7 @@ const shade = (wardIdx: number, n: number, [x, y, z]: [number, number, number]):
228
231
  name: `Shade${wardIdx}_${n}`,
229
232
  type: 'Node3D',
230
233
  groups: ['shade', `shade-ward${wardIdx}`],
231
- props: { position: [round2(x), round2(y + 0.9), round2(z)] },
234
+ props: { position: [round2(x), round2(y + 0.9), round2(z)], snapToGround: false },
232
235
  script: { name: 'PathFollow', props: { speed: 2.6, loop: true } },
233
236
  children: [
234
237
  {
@@ -411,6 +414,9 @@ const scene = {
411
414
  round2(heightAt(keeperSite[0] - 5, keeperSite[2] - 4)),
412
415
  round2(keeperSite[2] - 4),
413
416
  ],
417
+ // The lamp sits at the top of the tower: the ASSEMBLY owns its
418
+ // children's height, so `findFloatingProps` is not asked about them.
419
+ snapToGround: false,
414
420
  static: true,
415
421
  },
416
422
  children: [
@@ -462,7 +468,7 @@ const scene = {
462
468
  {
463
469
  name: 'Keeper',
464
470
  type: 'Node3D',
465
- props: { position: keeperSite.map(round2) },
471
+ props: { position: keeperSite.map(round2), snapToGround: false },
466
472
  script: { name: 'Interactable', props: { action: 'interact', range: 3 } },
467
473
  children: [
468
474
  {
@@ -511,6 +517,9 @@ const scene = {
511
517
  name: 'Player',
512
518
  type: 'RigidBody3D',
513
519
  groups: ['player'],
520
+ // Its Health is saveable, and a save is keyed by uid — minted, pasted,
521
+ // and stable across regenerations for the same reason the HUD's are.
522
+ uid: 'n_3cqocoi9v0cvoxdv',
514
523
  script: { name: 'Health', props: { max: 100, invulnerableFor: 0.8 } },
515
524
  props: {
516
525
  position: [round2(playerSpawn[0]), round2(playerSpawn[1] + 1.2), round2(playerSpawn[2])],
@@ -519,11 +528,24 @@ const scene = {
519
528
  collider: { shape: 'capsule', radius: 0.34, height: 1 },
520
529
  },
521
530
  children: [
531
+ {
532
+ /*
533
+ * An open terrain has an EDGE. With no catch, walking off it falls
534
+ * forever — `incanto-playtest` calls that `fell` and treats it as a
535
+ * failure whatever the game set out to be.
536
+ */
537
+ name: 'Catch',
538
+ type: 'Respawn',
539
+ },
522
540
  {
523
541
  name: 'Controller',
524
542
  type: 'CharacterController3D',
525
543
  props: {
526
544
  view: 'free',
545
+ // The two `incanto-feel` prints a note about on every run, and the
546
+ // 3D character skill calls coyote time the single biggest one.
547
+ coyoteSeconds: 0.12,
548
+ jumpBufferSeconds: 0.15,
527
549
  camDistance: 5.2,
528
550
  eyeHeight: 1.1,
529
551
  maxSpeed: 4.4,
@@ -623,6 +645,7 @@ const scene = {
623
645
  // ROOT child (a positioned parent would double the offset on screen)
624
646
  props: {
625
647
  visible: false,
648
+ snapToGround: false,
626
649
  position: [
627
650
  round2(keeperSite[0] + 16),
628
651
  round2(heightAt(keeperSite[0] + 16, keeperSite[2] - 14)),
@@ -768,7 +791,10 @@ const scene = {
768
791
  {
769
792
  name: 'Quest',
770
793
  type: 'UiText',
771
- uid: 'n_bi_quest',
794
+ // MINTED by newUid() and pasted, not invented: a save is keyed by
795
+ // uid and this file is regenerated, so they have to be both
796
+ // crypto-shaped and stable across `bun run world`.
797
+ uid: 'n_3txwnq8m201xbwoz',
772
798
  props: {
773
799
  text: 'Find the lighthouse keeper [E]',
774
800
  size: 16,
@@ -779,20 +805,25 @@ const scene = {
779
805
  {
780
806
  name: 'HP',
781
807
  type: 'UiBar',
782
- uid: 'n_bi_hp',
808
+ uid: 'n_vp09cmk3syxo2lcz',
783
809
  props: { anchor: 'topRight', value: 100, max: 100, width: 170 },
784
810
  },
785
- { name: 'Banner', type: 'UiBanner', uid: 'n_bi_banner', props: { anchor: 'center' } },
811
+ {
812
+ name: 'Banner',
813
+ type: 'UiBanner',
814
+ uid: 'n_xwkphpoa7ac9w451',
815
+ props: { anchor: 'center' },
816
+ },
786
817
  {
787
818
  name: 'Dialogue',
788
819
  type: 'UiDialogue',
789
- uid: 'n_bi_dialogue',
820
+ uid: 'n_7wosfkulqmue3jpv',
790
821
  props: { anchor: 'bottom', charsPerSecond: 45 },
791
822
  },
792
823
  {
793
824
  name: 'Hint',
794
825
  type: 'UiText',
795
- uid: 'n_bi_hint',
826
+ uid: 'n_x3funbse7a8nkfnu',
796
827
  props: {
797
828
  text: 'WASD move · Shift sprint · E interact · click/F strike',
798
829
  size: 12,
@@ -811,7 +842,44 @@ const scene = {
811
842
  ],
812
843
  };
813
844
 
814
- await Bun.write(`${import.meta.dir}/src/game.scene.json`, `${JSON.stringify(scene, null, 2)}\n`);
845
+ /*
846
+ * A REGENERATED file keeps the identities the old one had.
847
+ *
848
+ * A uid is the key a save is written against, and this script overwrites the
849
+ * whole scene — so minting fresh ones every run would break every save on the
850
+ * next `bun run world`, and emitting none at all (which is what it used to do)
851
+ * left eighteen scripted nodes unkeyed, so a save could not record what the run
852
+ * consumed. `verify:uids` said so; nothing said it to whoever ran the command.
853
+ *
854
+ * Reused by PATH: the same seed builds the same island, so the same node is at
855
+ * the same place. Anything new gets a real `newUid()`.
856
+ */
857
+ const scenePath = `${import.meta.dir}/src/game.scene.json`;
858
+ const previous = new Map<string, string>();
859
+ try {
860
+ const old = JSON.parse(await Bun.file(scenePath).text()) as { root?: unknown };
861
+ const remember = (node: Record<string, unknown>, path: string): void => {
862
+ const here = `${path}/${String(node.name ?? '?')}`;
863
+ if (typeof node.uid === 'string') previous.set(here, node.uid);
864
+ for (const child of (node.children ?? []) as Record<string, unknown>[]) remember(child, here);
865
+ };
866
+ if (old.root) remember(old.root as Record<string, unknown>, '');
867
+ } catch {
868
+ // No previous file: every uid below is minted fresh, which is the first run.
869
+ }
870
+
871
+ const keyIdentities = (node: Record<string, unknown>, path: string): void => {
872
+ const here = `${path}/${String(node.name ?? '?')}`;
873
+ // Only what a save would write down: a node carrying a behavior.
874
+ if (node.script && typeof node.uid !== 'string') node.uid = previous.get(here) ?? newUid();
875
+ else if (typeof node.uid === 'string' && previous.has(here))
876
+ node.uid = previous.get(here) as string;
877
+ for (const child of (node.children ?? []) as Record<string, unknown>[])
878
+ keyIdentities(child, here);
879
+ };
880
+ keyIdentities(scene.root as unknown as Record<string, unknown>, '');
881
+
882
+ await Bun.write(scenePath, `${JSON.stringify(scene, null, 2)}\n`);
815
883
  console.log(
816
884
  `beacon isle generated: keeper ${keeperSite.map(round2)}, wards ${wardSites.map((w) => w.map(round2)).join(' | ')}, ${placed} groves, ${grassPlaced} grass, ${flowersPlaced} flowers`,
817
885
  );
@@ -6,7 +6,7 @@
6
6
  "dev": "vite",
7
7
  "build": "vite build",
8
8
  "typecheck": "tsc --noEmit",
9
- "check": "incanto-check src",
9
+ "check": "incanto-check src --behaviors src/behaviors.ts",
10
10
  "verify": "bun verify.ts",
11
11
  "world": "bun generate-world.ts"
12
12
  },
@@ -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.69.0",
17
+ "incanto": "^0.71.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -88,6 +88,27 @@ function spawnFootDust(root: Node, at: [number, number, number], scale = 1): voi
88
88
  }
89
89
 
90
90
  export class IsleDirector extends Behavior {
91
+ /**
92
+ * The isle HAS an ending, and never said so.
93
+ *
94
+ * Three wards lit, the Shadeheart down, fireworks over the lighthouse and
95
+ * `BEACON ISLE SHINES` on the banner — and no `won` anywhere, so every
96
+ * instrument that asks the scene whether it is finishable answered no. On
97
+ * the template `bunx incanto-new my-game` scaffolds by DEFAULT:
98
+ *
99
+ * ```
100
+ * ? plays — nothing declares a win — 8 runs played without a defect,
101
+ * and there was no end to reach
102
+ * next: if it is meant to be finishable, emit `won`
103
+ * ```
104
+ *
105
+ * Both sentences were true about the WIRING and wrong about the game. A
106
+ * signal is the cheapest of the three routes the skill offers (`GameFlow`,
107
+ * `ScoreKeeper`, your own behaviour) and the only one that does not also
108
+ * freeze the world — the isle keeps playing after you win it.
109
+ */
110
+ static readonly signals: readonly string[] = ['won'];
111
+
91
112
  private wind: Voice | null = null;
92
113
  private nav: TerrainNav | null = null;
93
114
  private repathClock = 0;
@@ -445,6 +466,7 @@ export class IsleDirector extends Behavior {
445
466
  (this.node.getNode('WinSfx') as AudioPlayer).play();
446
467
  this.fireworksIn = 0.4;
447
468
  this.syncQuestLine();
469
+ this.node.emit('won');
448
470
  });
449
471
  return;
450
472
  }