incanto 0.53.0 → 0.55.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 (57) hide show
  1. package/bin/incanto-check.mjs +56 -1
  2. package/bin/incanto-verify.mjs +46 -0
  3. package/dist/2d.d.ts +46 -3
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +105 -6
  6. package/dist/3d.js +10 -6
  7. package/dist/{behavior-Do0Da56m.d.ts → behavior-CyQoSu4n.d.ts} +114 -10
  8. package/dist/{create-game-9D87XaiX.js → create-game-BRt6XKmP.js} +10 -6
  9. package/dist/{create-game-CDJ1lVqK.js → create-game-CzK9_pzg.js} +10 -6
  10. package/dist/debug.d.ts +1 -1
  11. package/dist/debug.js +4 -2
  12. package/dist/editor.js +7 -0
  13. package/dist/{environment-presets-SkGanr2s.js → environment-presets-DAbEdEwh.js} +326 -11
  14. package/dist/{gameplay-CRYw_Q-T.js → gameplay-CZ2yq37J.js} +30 -21
  15. package/dist/gameplay.d.ts +8 -3
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.js +5 -5
  19. package/dist/{loader-Cu_7kJDy.d.ts → loader-DhI1jFW_.d.ts} +1 -1
  20. package/dist/net.d.ts +2 -2
  21. package/dist/net.js +1 -1
  22. package/dist/{particle-sim-BzJ1yxoE.d.ts → particle-sim-C5OfBbmU.d.ts} +11 -0
  23. package/dist/{pathfinding-HNGFqGUZ.d.ts → pathfinding-CXGCpRQe.d.ts} +1 -1
  24. package/dist/{physics-2d-BhZZ-HAp.js → physics-2d-CfWAggJ1.js} +1 -1
  25. package/dist/{physics-3d-DmyO2oaN.js → physics-3d-C_ZJ6f_d.js} +2 -2
  26. package/dist/react.d.ts +1 -1
  27. package/dist/react.js +1 -1
  28. package/dist/{register-DROK2l7J.js → register-en63AEZO.js} +163 -4
  29. package/dist/{register-BTomIiYG.js → register-p48lHE2o.js} +130 -13
  30. package/dist/{replay-9Fy6C10F.d.ts → replay-ePMz26jw.d.ts} +1 -1
  31. package/dist/{replay-DYNUL4BU.js → replay-t1pP0gQg.js} +1 -1
  32. package/dist/{split-screen-DNmcX1Pz.d.ts → split-screen-BsdOHbzP.d.ts} +1 -1
  33. package/dist/{split-screen-CaF9hO7g.js → split-screen-CSb_uZ6W.js} +1 -1
  34. package/dist/{sprite-animation-D_p28jwU.js → sprite-animation-7qvUxF6Z.js} +19 -0
  35. package/dist/{src-C9xyZW7M.js → src-BVOVHRL0.js} +1 -1
  36. package/dist/{teardown-Bw2aeyGI.js → teardown-C7uVSJvx.js} +1 -1
  37. package/dist/{test-CJRsciFk.js → test-Ca5eqELC.js} +49 -14
  38. package/dist/test.d.ts +20 -4
  39. package/dist/test.js +2 -2
  40. package/dist/vite.js +2 -2
  41. package/editor/assets/{agent8-PlFHzJsh.js → agent8-Cz4oHPtm.js} +1 -1
  42. package/editor/assets/{debug-CebV7CDW.js → debug-r-Fi9nZh.js} +2 -2
  43. package/editor/assets/{index-CdbsY31G.js → index-BfqmvXYD.js} +91 -91
  44. package/editor/index.html +1 -1
  45. package/package.json +1 -1
  46. package/schemas/scene.schema.json +185 -0
  47. package/skills/incanto-assets.md +45 -2
  48. package/skills/incanto-building-2d-games.md +35 -0
  49. package/skills/incanto-building-3d-games.md +28 -3
  50. package/skills/incanto-environment.md +3 -1
  51. package/skills/incanto-gameplay-behaviors.md +51 -4
  52. package/skills/incanto-node-reference.md +31 -0
  53. package/skills/incanto-performance.md +51 -0
  54. package/skills/incanto-verifying-your-game.md +38 -0
  55. package/templates-app/beacon-isle-3d/package.json +1 -1
  56. package/templates-app/tps-3d/package.json +1 -1
  57. package/templates-app/village-quest-3d/package.json +1 -1
package/editor/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Incanto Scene Editor</title>
7
7
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%236ee7dc'/><text x='8' y='12' text-anchor='middle' font-size='11' font-family='monospace' fill='%230e1018'>i</text></svg>" />
8
- <script type="module" crossorigin src="./assets/index-CdbsY31G.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-BfqmvXYD.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.53.0",
3
+ "version": "0.55.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -262,6 +262,9 @@
262
262
  {
263
263
  "$ref": "#/$defs/Label"
264
264
  },
265
+ {
266
+ "$ref": "#/$defs/Label3D"
267
+ },
265
268
  {
266
269
  "$ref": "#/$defs/LoftMesh3D"
267
270
  },
@@ -3337,6 +3340,180 @@
3337
3340
  },
3338
3341
  "required": ["name", "type"]
3339
3342
  },
3343
+ "Label3D": {
3344
+ "type": "object",
3345
+ "properties": {
3346
+ "name": {
3347
+ "type": "string"
3348
+ },
3349
+ "uid": {
3350
+ "type": "string"
3351
+ },
3352
+ "type": {
3353
+ "const": "Label3D"
3354
+ },
3355
+ "groups": {
3356
+ "type": "array",
3357
+ "items": {
3358
+ "type": "string"
3359
+ }
3360
+ },
3361
+ "tags": {
3362
+ "type": "object"
3363
+ },
3364
+ "props": {
3365
+ "type": "object",
3366
+ "properties": {
3367
+ "position": {
3368
+ "type": "array",
3369
+ "items": {
3370
+ "type": "number"
3371
+ },
3372
+ "minItems": 3,
3373
+ "maxItems": 3,
3374
+ "default": [0, 0, 0]
3375
+ },
3376
+ "rotation": {
3377
+ "type": "array",
3378
+ "items": {
3379
+ "type": "number"
3380
+ },
3381
+ "minItems": 3,
3382
+ "maxItems": 3,
3383
+ "default": [0, 0, 0]
3384
+ },
3385
+ "static": {
3386
+ "type": "boolean",
3387
+ "default": false
3388
+ },
3389
+ "scale": {
3390
+ "type": "array",
3391
+ "items": {
3392
+ "type": "number"
3393
+ },
3394
+ "minItems": 3,
3395
+ "maxItems": 3,
3396
+ "default": [1, 1, 1]
3397
+ },
3398
+ "visible": {
3399
+ "type": "boolean",
3400
+ "default": true
3401
+ },
3402
+ "renderOrder": {
3403
+ "type": "number",
3404
+ "default": 0
3405
+ },
3406
+ "orderGroup": {
3407
+ "type": "string",
3408
+ "enum": ["background", "terrain", "default", "characters", "effects", "overlay"],
3409
+ "default": "default"
3410
+ },
3411
+ "snapToGround": {
3412
+ "default": null
3413
+ },
3414
+ "texture": {
3415
+ "type": "string",
3416
+ "default": ""
3417
+ },
3418
+ "size": {
3419
+ "type": "array",
3420
+ "items": {
3421
+ "type": "number"
3422
+ },
3423
+ "minItems": 2,
3424
+ "maxItems": 2,
3425
+ "default": [1, 1]
3426
+ },
3427
+ "billboard": {
3428
+ "type": "string",
3429
+ "enum": ["y", "full", "none"],
3430
+ "default": "full"
3431
+ },
3432
+ "anchor": {
3433
+ "type": "array",
3434
+ "items": {
3435
+ "type": "number"
3436
+ },
3437
+ "minItems": 2,
3438
+ "maxItems": 2,
3439
+ "default": [0.5, 0]
3440
+ },
3441
+ "tint": {
3442
+ "type": "string",
3443
+ "default": "#ffffff"
3444
+ },
3445
+ "opacity": {
3446
+ "type": "number",
3447
+ "default": 1
3448
+ },
3449
+ "flipX": {
3450
+ "type": "boolean",
3451
+ "default": false
3452
+ },
3453
+ "pixelArt": {
3454
+ "type": "boolean",
3455
+ "default": true
3456
+ },
3457
+ "alphaTest": {
3458
+ "type": "number",
3459
+ "default": 0.5
3460
+ },
3461
+ "castShadow": {
3462
+ "type": "boolean",
3463
+ "default": false
3464
+ },
3465
+ "shadowMode": {
3466
+ "type": "string",
3467
+ "enum": ["silhouette", "blob"],
3468
+ "default": "silhouette"
3469
+ },
3470
+ "text": {
3471
+ "type": "string",
3472
+ "default": ""
3473
+ },
3474
+ "height": {
3475
+ "type": "number",
3476
+ "default": 0.3
3477
+ },
3478
+ "color": {
3479
+ "type": "string",
3480
+ "default": "#ffffff"
3481
+ },
3482
+ "font": {
3483
+ "type": "string",
3484
+ "default": "sans-serif"
3485
+ },
3486
+ "outline": {
3487
+ "type": "string",
3488
+ "default": "#000000"
3489
+ }
3490
+ },
3491
+ "additionalProperties": false
3492
+ },
3493
+ "script": {
3494
+ "type": "object",
3495
+ "properties": {
3496
+ "name": {
3497
+ "type": "string"
3498
+ },
3499
+ "props": {
3500
+ "type": "object"
3501
+ }
3502
+ },
3503
+ "required": ["name"]
3504
+ },
3505
+ "network": {
3506
+ "type": "object"
3507
+ },
3508
+ "children": {
3509
+ "type": "array",
3510
+ "items": {
3511
+ "$ref": "#/$defs/node"
3512
+ }
3513
+ }
3514
+ },
3515
+ "required": ["name", "type"]
3516
+ },
3340
3517
  "LoftMesh3D": {
3341
3518
  "type": "object",
3342
3519
  "properties": {
@@ -4235,6 +4412,10 @@
4235
4412
  "type": "boolean",
4236
4413
  "default": true
4237
4414
  },
4415
+ "worldSpace": {
4416
+ "type": "boolean",
4417
+ "default": false
4418
+ },
4238
4419
  "rate": {
4239
4420
  "type": "number",
4240
4421
  "default": 40
@@ -4443,6 +4624,10 @@
4443
4624
  "type": "boolean",
4444
4625
  "default": true
4445
4626
  },
4627
+ "worldSpace": {
4628
+ "type": "boolean",
4629
+ "default": false
4630
+ },
4446
4631
  "rate": {
4447
4632
  "type": "number",
4448
4633
  "default": 40
@@ -146,6 +146,47 @@ Swap in real art later by changing ONLY the `assets{}` entry — node props
146
146
  stay untouched.
147
147
 
148
148
 
149
+ ## Art you named and never copied
150
+
151
+ `bunx incanto-check` warns when a scene asset points at a local file the project
152
+ does not contain:
153
+
154
+ ```
155
+ ok src/game.scene.json
156
+ warn: $hero → assets/hero.png is not in the project (looked in public/,
157
+ ./, and beside the scene). It will draw nothing.
158
+ ```
159
+
160
+ That used to be a browser-only failure — visible in `assetErrors()`, which needs
161
+ a running game and someone to look at it — and it is the single most common way
162
+ a scene draws nothing. Remote (`https:`) and inline (`data:`) urls are left
163
+ alone: the command cannot know, and guessing would be worse than the bug.
164
+
165
+ ## A spritesheet grid that does not fit says so
166
+
167
+ `frameWidth`/`frameHeight` are the two numbers nothing could check for you, and
168
+ getting them wrong does not fail — it draws the wrong art. A size that does not
169
+ divide the sheet slices every row a little further off centre; an animation
170
+ naming a frame past the end of the grid reads whatever is at the wrong end of
171
+ the image.
172
+
173
+ Both are reported now, once, the first time the sprite draws:
174
+
175
+ ```
176
+ [incanto] spritesheet 'hero' does not fit its frame size: 1344 px wide is not a
177
+ whole number of 100 px frames (672, 448, 336, 224, 192, 112 would divide it).
178
+ Every row after the first is cut off centre.
179
+
180
+ [incanto] spritesheet 'hero' has 35 frames (0–34), and an animation asks for
181
+ frame 40. Those frames draw whatever is at the wrong end of the sheet.
182
+ ```
183
+
184
+ The suggested sizes are the ones that actually divide your image, so the fix is
185
+ usually in the message. `bunx incanto-assets info <name>` prints the real frame
186
+ size for a built-in, and the editor's 📚 picker fills it in from the catalog's
187
+ metadata — or leaves it BLANK when the catalog does not carry it, because an
188
+ invented frame size is exactly this bug.
189
+
149
190
  ## Textures are shared automatically (3D)
150
191
 
151
192
  A hundred `Sprite3D`s pointing at one atlas cost **one** fetch, one decode and
@@ -162,9 +203,11 @@ Two consequences worth knowing:
162
203
  - **`pixelArt: true` also turns mipmaps off**, which is what makes pixel art stay
163
204
  crisp at distance instead of blurring into mud.
164
205
 
165
- A texture that 404s now shows up in `game.assetErrors()` alongside models, by the
206
+ A texture that 404s shows up in `game.assetErrors()` alongside models, by the
166
207
  URL you wrote — so "why is my sprite invisible" is answerable without opening the
167
- network tab. In **2D** the same question is `renderer.assets.errors()`
208
+ network tab. **That includes vegetation**: a `Tree3D` leaf or bark URL that fails
209
+ used to leave a grove of bare branches and say nothing anywhere, because that
210
+ node keeps its own texture cache. In **2D** the same question is `renderer.assets.errors()`
168
211
  (`$ref`, url and reason per failed entry), and the scene EDITOR reads it: a
169
212
  failed asset is red in the explorer with the url in its tooltip and the
170
213
  consequence in its inspector.
@@ -291,6 +291,41 @@ solid core carries the colour). `rate: 0` +
291
291
  { "signal": "finished", "from": "Boom", "to": ".", "handler": "onBoomDone" }
292
292
  ```
293
293
 
294
+ ### Where a particle LIVES: `worldSpace`
295
+
296
+ A particle is born in the emitter's LOCAL space by default, so the whole plume
297
+ moves with the node — right for a torch on a moving platform, wrong for almost
298
+ everything else:
299
+
300
+ - dust under a running player is GLUED to the player instead of left behind;
301
+ - and the standard way to place a one-shot — move the emitter, `replay()` —
302
+ drags the PREVIOUS burst across the level with it.
303
+
304
+ `"worldSpace": true` makes a particle keep the place it was born:
305
+
306
+ ```jsonc
307
+ { "name": "Dust", "type": "Particles2D",
308
+ "props": { "preset": "smoke", "worldSpace": true } }
309
+ ```
310
+
311
+ It follows a moving PARENT too, not just the node's own position. Default is
312
+ `false` so existing scenes look exactly as they did.
313
+
314
+ ### Armed, but not yet: `emitting: false`
315
+
316
+ A one-shot with `burst` fires on its FIRST FRAME — which is what you want for a
317
+ firework that is meant to go off as the level opens, and not at all what you
318
+ want for the twenty explosions authored on twenty destructible crates. Set
319
+ `"emitting": false` to arm one and hold it:
320
+
321
+ ```jsonc
322
+ { "name": "Boom", "type": "Particles2D",
323
+ "props": { "preset": "explosion", "rate": 0, "burst": 60, "emitting": false } }
324
+ ```
325
+
326
+ `node.replay()` fires it — that is the game asking, so `emitting: false` does
327
+ not hold it back — and re-arms it for the next time.
328
+
294
329
  `node.replay()` re-arms a one-shot. Simulation is deterministic under the
295
330
  engine seed — runScript verifies particle gameplay reproducibly.
296
331
 
@@ -183,8 +183,31 @@ camera yaw.
183
183
  A transform container that orients itself toward the camera every rendered frame;
184
184
  **children inherit the rotation** through the scene graph. Hang world-space UI
185
185
  under it — HP bars, pickup markers, floating icons — instead of hand-rolling
186
- camera math in game code. (There is no 3D text node — a nameplate or damage
187
- number is a canvas-rendered texture on a child `Sprite3D`.)
186
+ camera math in game code.
187
+
188
+ ### `Label3D` — text standing in the world
189
+
190
+ A nameplate, a sign, a damage number:
191
+
192
+ ```json
193
+ { "name": "Name", "type": "Label3D",
194
+ "props": { "text": "Goblin", "height": 0.25, "position": [0, 2, 0] } }
195
+ ```
196
+
197
+ - **`height`** is the cap height in METRES, and the quad follows what the text
198
+ measures — a sign is as wide as its words.
199
+ - It **faces the camera** by default (`billboard: "full"`): a damage number read
200
+ from behind is a bug. `"y"` keeps it upright for a nameplate over a character.
201
+ - **`outline`** is on by default, because white text over a bright wall is
202
+ unreadable and a damage number lands anywhere. `""` turns it off.
203
+ - It IS a `Sprite3D`, so `anchor`, `tint`, `opacity`, `alphaTest`, `renderOrder`
204
+ and `orderGroup` all work exactly as they do there.
205
+
206
+ A **damage number** is this node plus two lines: set `text`, then float it up
207
+ and fade `opacity` to 0 over ~0.6 s and `queueFree()`. Re-rasterising is
208
+ handled — the texture is rebuilt only when the text (or size/colour/font)
209
+ changes, and the old one is disposed, so a number per hit does not leak a
210
+ texture per hit.
188
211
 
189
212
  | Prop | Default | Notes |
190
213
  |---|---|---|
@@ -463,7 +486,9 @@ block.
463
486
  ## Particles
464
487
 
465
488
  `Particles3D` mirrors `Particles2D` (same presets — `fire`, `explosion`,
466
- `magic`… and the same one-shot `rate: 0` + `burst` → `finished` pattern) as
489
+ `magic`… the same one-shot `rate: 0` + `burst` → `finished` pattern, and the
490
+ same `worldSpace` prop, which is what stops a moved one-shot dragging the
491
+ previous explosion across the level) as
467
492
  point sprites in meters: preset distances auto-scale ÷100 (presets are authored
468
493
  in 2D px). See incanto-building-2d-games for the full prop list.
469
494
 
@@ -1042,7 +1042,9 @@ emitter only when you want a specific custom burst:
1042
1042
 
1043
1043
  (`replay` re-arms the one-shot burst; a small behavior can also move the
1044
1044
  emitter to the body first — the signal hands you the node:
1045
- `onEntered(body) { splash.position = body.position; splash.replay(); }`.)
1045
+ `onEntered(body) { splash.position = body.position; splash.replay(); }` — give
1046
+ that emitter `"worldSpace": true`, or the second splash drags the first one to
1047
+ the new spot.)
1046
1048
 
1047
1049
  ## River3D — running water
1048
1050
 
@@ -815,12 +815,59 @@ import { CameraShake, Cooldown, hitStop, screenFlash } from 'incanto/gameplay';
815
815
  meters). For cameras WITHOUT a follow script attach the standalone
816
816
  `CameraShake` behavior — it composes with any other position writer and
817
817
  returns the camera exactly to base.
818
- - **screenFlash(color?, opacity?, seconds?)** — full-screen damage/pickup
819
- flash (DOM, headless no-op).
818
+ - **screenFlash(engine, color?, opacity?, seconds?)** — full-screen damage/
819
+ pickup flash. DOM, so it draws nothing headless — but it REPORTS (below), so
820
+ you can still check that it fired.
820
821
  - **hitStop(engine, seconds?)** — freezes `engine.timeScale` for REAL
821
- seconds then restores; stacked calls extend. Sells melee impacts.
822
+ seconds then restores; stacked calls extend. Sells melee impacts. It measures
823
+ those seconds on `engine.unscaledTime`, so it thaws in a headless session
824
+ exactly as it does in a browser.
822
825
  - **engine.timeScale** — 1 realtime, 0.5 slow motion, 0 pause: scales
823
- variable AND fixed updates together (physics, timers, behaviors).
826
+ variable AND fixed updates together (physics, timers, behaviors) — and it
827
+ means the same thing headless, so a pause menu and a slow-motion finish can be
828
+ tested. (A stepped frame still HAPPENS while paused: `update` runs with
829
+ `dt = 0`, which is how the pause menu's own key polling keeps working.)
830
+
831
+ ## Did the effect fire?
832
+
833
+ Sound has `engine.audio`; vision has **`engine.effects`**, and the question is
834
+ the same one. `framing()` says where an emitter IS — a particle system that
835
+ never fired and one that fired a hundred times sit at the same coordinates — and
836
+ shake, flash and hit-stop draw nothing at all in a run with no screen.
837
+
838
+ ```ts
839
+ session.engine.effects.clearLog();
840
+ smashTheCrystal();
841
+ session.step(200);
842
+
843
+ session.engine.effects.countOf('explosion'); // 1
844
+ session.engine.effects.countFrom('/Game/Crystal/Boom'); // 1
845
+ session.engine.effects.recent();
846
+ // [{ kind: 'burst', name: 'explosion', from: '/Game/Crystal/Boom', amount: 60, at: 1.2 }]
847
+ ```
848
+
849
+ - `kind` — `burst` (a one-shot fired) · `emit` (a continuous emitter STARTED —
850
+ once, not once per particle) · `trail` (a `Trail3D` started laying ribbon) ·
851
+ `shake` · `flash` · `hitstop`.
852
+ - `name` — the particle preset, or the flash colour.
853
+ - `from` — the node path, or `engine` for the screen-wide ones.
854
+ - `amount` — particles in the burst, shake magnitude, seconds frozen.
855
+ - Bounded at 200, `clearLog()` between assertions.
856
+
857
+ A `Trail3D`'s ribbon is a special case worth knowing: it is laid in the RENDER
858
+ pass, because each point needs the node's real world transform (a sword arc is a
859
+ rotating parent). So `trail.pointCount` is **0 headless** — that is the verify VM
860
+ working, not a broken trail — and the `trail` event above is what tells you the
861
+ wiring fired.
862
+
863
+ It records that the effect was ASKED FOR, not that a pixel moved — the burst
864
+ that never happened because the signal was never connected looks exactly like
865
+ the burst that happened off-screen, and this tells them apart. For "is it
866
+ running right now", a particle node also has **`aliveCount`**.
867
+
868
+ A shake suppressed by `reduceMotion` records NOTHING, on purpose: it did not
869
+ happen, and saying it did would send you hunting a camera bug that is an
870
+ accessibility setting.
824
871
 
825
872
  ## Game flow (win / lose / restart)
826
873
 
@@ -479,6 +479,35 @@ Signals: `movementStateChanged(state)`
479
479
  | `font` | `"monospace"` | string |
480
480
  | `align` | `"left"` | string |
481
481
 
482
+ ## `Label3D` — `incanto`
483
+
484
+ | Prop | Default | Kind |
485
+ |---|---|---|
486
+ | `position` | `[0,0,0]` | array |
487
+ | `rotation` | `[0,0,0]` | array |
488
+ | `static` | `false` | boolean |
489
+ | `scale` | `[1,1,1]` | array |
490
+ | `visible` | `true` | boolean |
491
+ | `renderOrder` | `0` | number |
492
+ | `orderGroup` | `"default"` | one of: `background` `terrain` `default` `characters` `effects` `overlay` |
493
+ | `snapToGround` | `null` | null |
494
+ | `texture` | `""` | string |
495
+ | `size` | `[1,1]` | array |
496
+ | `billboard` | `"full"` | one of: `y` `full` `none` |
497
+ | `anchor` | `[0.5,0]` | array |
498
+ | `tint` | `"#ffffff"` | string |
499
+ | `opacity` | `1` | number |
500
+ | `flipX` | `false` | boolean |
501
+ | `pixelArt` | `true` | boolean |
502
+ | `alphaTest` | `0.5` | number |
503
+ | `castShadow` | `false` | boolean |
504
+ | `shadowMode` | `"silhouette"` | one of: `silhouette` `blob` |
505
+ | `text` | `""` | string |
506
+ | `height` | `0.3` | number |
507
+ | `color` | `"#ffffff"` | string |
508
+ | `font` | `"sans-serif"` | string |
509
+ | `outline` | `"#000000"` | string |
510
+
482
511
  ## `LoftMesh3D` — `incanto/3d`
483
512
 
484
513
  | Prop | Default | Kind |
@@ -613,6 +642,7 @@ _No props (structural fields only)._
613
642
  | `visible` | `true` | boolean |
614
643
  | `preset` | `"custom"` | one of: `custom` `fire` `smoke` `sparks` `fireworks` `explosion` `flash` `lightning` `rain` `snow` `magic` |
615
644
  | `emitting` | `true` | boolean |
645
+ | `worldSpace` | `false` | boolean |
616
646
  | `rate` | `40` | number |
617
647
  | `burst` | `0` | number |
618
648
  | `lifetime` | `[0.6,1.2]` | array |
@@ -648,6 +678,7 @@ Signals: `finished`
648
678
  | `snapToGround` | `null` | null |
649
679
  | `preset` | `"custom"` | one of: `custom` `fire` `smoke` `sparks` `fireworks` `explosion` `flash` `lightning` `rain` `snow` `magic` |
650
680
  | `emitting` | `true` | boolean |
681
+ | `worldSpace` | `false` | boolean |
651
682
  | `rate` | `40` | number |
652
683
  | `burst` | `0` | number |
653
684
  | `lifetime` | `[0.6,1.2]` | array |
@@ -13,6 +13,41 @@ You do not implement any of this. You decide whether to expose it.
13
13
 
14
14
  ---
15
15
 
16
+ ## First: where did the frame go?
17
+
18
+ A settings menu is what you offer a PLAYER. This is what you ask the game.
19
+
20
+ ```ts
21
+ game.stats();
22
+ // { fps: 31, frameMs: 32.1, nodes: 4210, triangles: 890_000, drawCalls: 340,
23
+ // phases: { fixedMs: 21.4, updateMs: 2.2, renderMs: 6.1, otherMs: 2.4 } }
24
+ ```
25
+
26
+ `fps` and `frameMs` are a total, and a total has no next question. The four
27
+ slices say which part of the frame to look at:
28
+
29
+ | slice | what is in it | what to do about it |
30
+ | --- | --- | --- |
31
+ | `fixedMs` | physics and everything on `fixedUpdate` | fewer/simpler colliders, `fixedHz`, sleep distant bodies |
32
+ | `updateMs` | behaviors, node logic, tweens | the per-frame work your game does — profile it in your own code |
33
+ | `renderMs` | the renderer, reported by itself | quality tier, `renderScale`, draw calls, shadows |
34
+ | `otherMs` | the frame MINUS the three above | GC, browser layout, your own rAF work. **Allocations show up here.** |
35
+
36
+ The debug overlay's stats chip shows the same four numbers live
37
+ (`phys · logic · draw · other`), so you do not have to call anything to see a
38
+ frame go wrong while you are playing.
39
+
40
+ `otherMs` is the one worth knowing about. A frame drop that everyone reads as a
41
+ GPU problem is often garbage collection, and it looks identical from the outside
42
+ — this engine lost a day to exactly that. A big `otherMs` with small everything
43
+ else means you are allocating per frame: replace arrays and objects created in
44
+ `update()` with reused ones.
45
+
46
+ Averages over the same rolling window as `frameMs`, so the numbers add up to it
47
+ and are comparable to each other. Headless (`step()`), `fps`/`frameMs` are 0 and
48
+ the slices are still real CPU times — a behavior that got slower shows up in a
49
+ test.
50
+
16
51
  ## The short version
17
52
 
18
53
  ```ts
@@ -32,6 +67,22 @@ Every one of those is written to the save store and comes back on the next launc
32
67
 
33
68
  ---
34
69
 
70
+ ## Leaks: a level swap has to give the GPU back
71
+
72
+ `stats().geometries` and `stats().textures` are the leak witnesses — they count
73
+ what three is holding right now. Swap levels a few times and read them again:
74
+
75
+ ```ts
76
+ const before = game.stats().geometries;
77
+ await loadLevel(2);
78
+ await loadLevel(1);
79
+ game.stats().geometries; // should be ~before, not 2x before
80
+ ```
81
+
82
+ Every node that builds geometry releases it when it is freed, and a scene swap
83
+ frees the old tree. If your own code holds a three object (a custom behavior
84
+ that built a mesh), dispose it in the behavior's `onExitTree`.
85
+
35
86
  ## The four levers, and what each actually costs
36
87
 
37
88
  | setting | values | costs | changes live |
@@ -32,6 +32,7 @@ failures — but `framing` and `assetErrors()` you have to ASK for.
32
32
  $ bunx incanto-verify # finds your scene
33
33
  ✓ loads — the scene is legal and its assets resolve
34
34
  ? plays — nothing declares a win — 8 runs played without error, and there was no end to reach
35
+ ✓ feels — 5 of 7 fired — silent: /Game/Boss/Roar, /Game/Boss/Boom
35
36
  ? draws — the dev server is running on :5173, but no page answered
36
37
 
37
38
  passes what was measured — plays, draws not measured.
@@ -49,6 +50,12 @@ encodes so you do not have to remember them:
49
50
  - a scene that declares **no win** is not a scene that cannot be won. A
50
51
  walkabout has no end, and that is reported as unmeasured, not failed.
51
52
 
53
+ `feels` is the sound-and-effects rung: it lists what the scene DECLARES against
54
+ what actually fired during the playtest. A game whose feedback is wired but
55
+ never triggered plays perfectly and feels dead, and every other rung calls it
56
+ healthy. Nothing firing at all is a failure; some nodes staying silent is
57
+ reported by name, because the bot may simply never have reached the boss.
58
+
52
59
  Exit 1 only when a rung actually failed. `--behaviors src/behaviors.ts` matters:
53
60
  without it your game logic is stubbed and only the structure is played.
54
61
 
@@ -679,6 +686,37 @@ expect(session.engine.audio.countOf('hit')).toBe(1);
679
686
  time; `countOf(name)` counts one. A clip whose FILE is broken shows up in
680
687
  `assetErrors()` instead. Full guide: `incanto-audio.md`.
681
688
 
689
+ ## Speed: which part of the frame is expensive?
690
+
691
+ ```ts
692
+ game.stats().phases;
693
+ // { fixedMs: 21.4, updateMs: 2.2, renderMs: 6.1, otherMs: 2.4 }
694
+ ```
695
+
696
+ Physics, behaviors, the renderer, and everything else. The last one is where GC
697
+ and per-frame allocation hide, and it is the slice a screenshot can never show.
698
+ See `incanto-performance.md`.
699
+
700
+ ## Sight: did the right thing SHOW?
701
+
702
+ The same question as sound, about the other half of a game's feedback. Nothing
703
+ renders in the VM, so shake, flash and freeze frames are no-ops and a particle
704
+ system that never fired sits at exactly the same coordinates as one that fired a
705
+ hundred times — `framing()` reports WHERE an emitter is, never whether it went
706
+ off.
707
+
708
+ ```ts
709
+ session.engine.effects.clearLog();
710
+ smashTheCrystal();
711
+ session.step(200);
712
+ expect(session.engine.effects.countOf('explosion')).toBe(1);
713
+ ```
714
+
715
+ `engine.effects.recent()` is the last 200 effects — `kind` (`burst` · `emit` ·
716
+ `shake` · `flash` · `hitstop`), the preset or colour, the node path, and how big.
717
+ `countFrom(path)` asks about one emitter, which is usually the wiring question.
718
+ A particle node's `aliveCount` answers the other one: is it running right now.
719
+
682
720
  ## Multiplayer: do the players end up in the SAME world?
683
721
 
684
722
  Every rung above asks about one player. A multiplayer game's first question is
@@ -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.53.0",
17
+ "incanto": "^0.55.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.53.0",
16
+ "incanto": "^0.55.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.53.0",
16
+ "incanto": "^0.55.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {