incanto 0.67.0 → 0.69.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.
- package/assets/catalog.json +9 -5
- package/bin/_pick-server.mjs +48 -0
- package/bin/incanto-assets.mjs +14 -1
- package/bin/incanto-check.mjs +69 -5
- package/bin/incanto-frame.mjs +10 -25
- package/bin/incanto-logs.mjs +8 -18
- package/bin/incanto-new.mjs +29 -1
- package/bin/incanto-serve.mjs +160 -0
- package/bin/incanto-verify.mjs +40 -0
- package/bin/incanto.mjs +1 -0
- package/dist/2d.d.ts +10 -8
- package/dist/2d.js +6 -4
- package/dist/3d.d.ts +31 -7
- package/dist/3d.js +9 -7
- package/dist/{audio-player-BNUHo0JF.d.ts → audio-player-C0j2k-yB.d.ts} +80 -3
- package/dist/{behavior-ZnfzKDo3.d.ts → behavior-rm8vVzu_.d.ts} +145 -1
- package/dist/boot-failure-CKYrEcGF.js +47 -0
- package/dist/{create-game-FAXdHta3.js → create-game-CvJ1wSRP.js} +27 -27
- package/dist/{create-game-DMetd3vR.js → create-game-CwxEDlCm.js} +15 -11
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-B8QnX2-r.js → duplicate-Dj4L-5AA.js} +1 -1
- package/dist/{editor-switch-DAvWQeld.d.ts → editor-switch-CnIOiyNJ.d.ts} +1 -1
- package/dist/editor.js +12 -6
- package/dist/env.d.ts +1 -1
- package/dist/{environment-presets-C-5g2pMh.js → environment-presets-BO-7XoFV.js} +58 -9
- package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
- package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
- package/dist/{gameplay-CGSv-9XR.js → gameplay-D1KixWK8.js} +175 -151
- package/dist/gameplay.d.ts +51 -3
- package/dist/gameplay.js +2 -2
- package/dist/index.d.ts +6 -6
- package/dist/index.js +8 -7
- package/dist/{loader-KZESOZmE.js → loader-DZE7B07H.js} +48 -7
- package/dist/{loader-DyT30ArB.d.ts → loader-DadV2QCT.d.ts} +2 -2
- package/dist/net.d.ts +2 -2
- package/dist/net.js +1 -1
- package/dist/{physics-2d-_B6zj3zU.js → physics-2d-CF5yyjbj.js} +2 -2
- package/dist/{physics-3d-i1IeAY0F.js → physics-3d-DbYaEcZy.js} +3 -3
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/{register-vIxU3Xbv.js → register-DbVArsns.js} +19 -10
- package/dist/{register-Cs53CY7s.js → register-mmfugpaQ.js} +159 -6
- package/dist/{replay-BkpDV51O.js → replay-BEHLDcQV.js} +28 -5
- package/dist/{replay-D0GcEHYU.d.ts → replay-BQfkX4x7.d.ts} +38 -4
- package/dist/{schema-CFeioQRE.d.ts → schema-B6ugCV1Q.d.ts} +11 -0
- package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
- package/dist/{split-screen-DS8RaUV_.js → split-screen-CauMwu6y.js} +2 -2
- package/dist/{split-screen-DD5eYIgW.d.ts → split-screen-Cg76MCV5.d.ts} +3 -3
- package/dist/{src-BLlsGJW9.js → src-CYEu-_S9.js} +1 -1
- package/dist/{teardown-LB9cTnXJ.js → teardown-CMqFVxO9.js} +5 -4
- package/dist/{test-81uZ967a.js → test-D6ISmuFE.js} +73 -25
- package/dist/test.d.ts +17 -6
- package/dist/test.js +3 -3
- package/dist/vite.d.ts +70 -2
- package/dist/vite.js +176 -14
- package/dist/{rendering-options-ByAdi8PF.js → webgl-unavailable-Z5_g8x9H.js} +57 -2
- package/editor/assets/{agent8-C2jtNKbx.js → agent8-BDvaZMQJ.js} +1 -1
- package/editor/assets/{debug-DoU3KeqS.js → debug-CMULinBW.js} +1 -1
- package/editor/assets/{index-CNqHojcC.js → index-CZIRr5EL.js} +92 -92
- package/editor/index.html +1 -1
- package/package.json +4 -3
- package/schemas/scene.schema.json +4 -0
- package/skills/incanto-3d-character.md +1 -0
- package/skills/incanto-assets.md +16 -2
- package/skills/incanto-audio.md +65 -0
- package/skills/incanto-building-2d-games.md +66 -0
- package/skills/incanto-building-3d-games.md +97 -2
- package/skills/incanto-gameplay-behaviors.md +46 -2
- package/skills/incanto-node-reference.md +1 -0
- package/skills/incanto-physics-and-input.md +23 -0
- package/skills/incanto-save-slots.md +27 -0
- package/skills/incanto-scene-json-authoring.md +12 -1
- package/skills/incanto-verifying-your-game.md +96 -2
- package/skills/incanto-your-first-game.md +2 -1
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/main.ts +21 -4
- package/templates-app/platformer-2d/index.html +0 -23
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/platformer-2d/src/behaviors.ts +26 -16
- package/templates-app/platformer-2d/src/game.scene.json +17 -27
- package/templates-app/platformer-2d/src/main.ts +32 -13
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/star-survivor/src/main.ts +25 -7
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/main.ts +34 -16
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/main.ts +21 -4
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-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-CZIRr5EL.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.
|
|
3
|
+
"version": "0.69.0",
|
|
4
4
|
"description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"game-engine",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "tsdown",
|
|
56
56
|
"dev": "tsdown --watch",
|
|
57
|
-
"typecheck": "tsc --noEmit",
|
|
57
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
|
58
58
|
"test": "vitest run --project unit --root ../.."
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"incanto-new": "bin/incanto-new.mjs",
|
|
103
103
|
"incanto-frame": "bin/incanto-frame.mjs",
|
|
104
104
|
"incanto-verify": "./bin/incanto-verify.mjs",
|
|
105
|
-
"incanto-logs": "./bin/incanto-logs.mjs"
|
|
105
|
+
"incanto-logs": "./bin/incanto-logs.mjs",
|
|
106
|
+
"incanto-serve": "./bin/incanto-serve.mjs"
|
|
106
107
|
}
|
|
107
108
|
}
|
|
@@ -54,6 +54,7 @@ did not until now.
|
|
|
54
54
|
| `maxJumps` | `1` | `2` = double jump. Extra jumps work in mid-air. |
|
|
55
55
|
| `airControl` | `0.2` | how much ground control you keep airborne (0 = committed, 1 = full). |
|
|
56
56
|
| `fallGravity` | `2.5` | gravity multiplier while falling. Higher = snappier arc. |
|
|
57
|
+
| `platformCarry` | `true` | ride whatever you are standing on. Off = the controller steers in WORLD space, so standing still on a moving floor brakes you off it (measured: 95.9% of a moving deck's travel kept, 0.0% with this off). |
|
|
57
58
|
|
|
58
59
|
Every one defaults to OFF (`0` / `1`) or to the previous hard-coded constant, so
|
|
59
60
|
a scene that asks for nothing behaves exactly as before.
|
package/skills/incanto-assets.md
CHANGED
|
@@ -21,13 +21,27 @@ playable character — model, locomotion clips, body, controller, skin and input
|
|
|
21
21
|
## 1. Built-in assets (in the package)
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
bunx incanto-assets list #
|
|
24
|
+
bunx incanto-assets list # name · kind · GRID · what it is
|
|
25
25
|
bunx incanto-assets info medieval-knight # description + animation names
|
|
26
26
|
bunx incanto-assets copy medieval-knight --out public/assets
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
`list --json` prints every entry. Each entry carries a **`url`** — the drop-in
|
|
30
|
-
reference you put in scene JSON so the asset LOADS (the contract, see §1b)
|
|
30
|
+
reference you put in scene JSON so the asset LOADS (the contract, see §1b) —
|
|
31
|
+
and, where the art has a grid, the numbers a node needs:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
2dbasic character [animated] 111×83 2dbasic sprite sheet image…
|
|
35
|
+
minecraft-tiles tile 16×16 (25 tiles) Minecraft-themed tiles…
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`frameWidth`/`frameHeight` are what `AnimatedSprite2D.frameWidth` and
|
|
39
|
+
`TileMap2D.tileSize` want; `columns` and `tiles` tell you the highest index a
|
|
40
|
+
`legend` may name (25 tiles means 0–24, and asking for 99 draws clamp-streaks —
|
|
41
|
+
the engine reports it, but the catalog is where you get the number). **The sizes
|
|
42
|
+
live in these fields and nowhere else** — a frame size in the description was a
|
|
43
|
+
second copy, and it drifted: `2dbasic` said 192×192 for art whose real grid is
|
|
44
|
+
111×83.
|
|
31
45
|
|
|
32
46
|
### Categories — the whole built-in set
|
|
33
47
|
|
package/skills/incanto-audio.md
CHANGED
|
@@ -396,6 +396,60 @@ changed under it. `engine.music` deliberately survives a scene swap (it belongs
|
|
|
396
396
|
to the engine, not the scene): call `engine.music.stop(1)` or `crossfadeTo` when
|
|
397
397
|
the music should change.
|
|
398
398
|
|
|
399
|
+
## Sounds on a GRID: rhythm games and anything charted to a soundtrack
|
|
400
|
+
|
|
401
|
+
A sound fired from `update()` cannot land closer than one frame to where a chart
|
|
402
|
+
wants it — 16.67 ms at 60 Hz, 33.33 ms at 30, and that is the whole difference
|
|
403
|
+
between a rhythm game that feels tight and one that does not. The engine's frame
|
|
404
|
+
clock is exact (3600 steps land on `engine.time` 60.000000000 s and beat error
|
|
405
|
+
never compounds); the last 16 ms is the gap.
|
|
406
|
+
|
|
407
|
+
Schedule on the AUDIO clock instead. Queue a short LOOKAHEAD ahead of now, every
|
|
408
|
+
frame, and let Web Audio place the sound:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
const LEAD = 0.08; // schedule this far ahead of the speaker
|
|
412
|
+
let cursor = 0; // next un-queued note
|
|
413
|
+
|
|
414
|
+
engine.updated.connect(() => {
|
|
415
|
+
const horizon = engine.sfx.now + LEAD;
|
|
416
|
+
while (cursor < chart.length && startedAt + chart[cursor].atSec <= horizon) {
|
|
417
|
+
hit.playAt(startedAt + chart[cursor].atSec); // NOT play() — see below
|
|
418
|
+
cursor++;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- **`engine.sfx.now`** — seconds on the audio clock, the one the sound is
|
|
424
|
+
actually placed on. `0` headless and before the first sound.
|
|
425
|
+
- **`AudioPlayer.playAt(when)`** and **`engine.sfx.play(params, gain, { when })`** —
|
|
426
|
+
PRESETS only. A `src` clip goes through an `<audio>` element, which has no
|
|
427
|
+
scheduling clock, so `playAt` on one plays immediately.
|
|
428
|
+
- **`play()` takes no arguments, deliberately.** It is the method scenes wire
|
|
429
|
+
signals to, and a signal hands its handler whatever it carries — `collected`
|
|
430
|
+
leads with a number, so `collected → play` would have become `play(10)` and
|
|
431
|
+
scheduled the pickup sound at absolute audio-clock second 10. Scheduling has
|
|
432
|
+
its own name.
|
|
433
|
+
- A time already past plays immediately (Web Audio's own rule), so a scheduler
|
|
434
|
+
that ran late is late, not silent.
|
|
435
|
+
- Everything else still applies: the bus gain, `engine.audio.recent()`, and
|
|
436
|
+
`muted`. A scheduler hand-rolled on a raw `AudioContext` hits the same
|
|
437
|
+
accuracy and loses all three.
|
|
438
|
+
|
|
439
|
+
Measured: 0 of 64 notes were given a scheduled start on the frame path; the same
|
|
440
|
+
64 queued this way land at **|mean| 0.000000 ms**.
|
|
441
|
+
|
|
442
|
+
**Charting against a music FILE** needs the playhead of the thing you can hear,
|
|
443
|
+
not a counter you started next to `music.play()` (a different clock, which keeps
|
|
444
|
+
counting through a gesture-block, a stall or a seek):
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
const songSeconds = engine.music.playhead; // null = nothing playing, or no clock
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
It is the raw element time, so a looping track wraps to 0 each pass — a loop
|
|
451
|
+
boundary to chart against, not a fault.
|
|
452
|
+
|
|
399
453
|
## Decision guide
|
|
400
454
|
|
|
401
455
|
- **Need a quick game sound (coin/jump/hit/explosion/…)** → set `preset`. Done.
|
|
@@ -430,6 +484,17 @@ session.engine.audio.recent();
|
|
|
430
484
|
its volume comes from. `countOf(name)` is the assertion you usually want;
|
|
431
485
|
`clearLog()` resets between steps.
|
|
432
486
|
|
|
487
|
+
**A LOOPING sound is recorded once, and its entry carries `loop: true`.**
|
|
488
|
+
Re-recording every pass would evict the rest of the log within seconds for a
|
|
489
|
+
0.2 s preset, so the count stays 1 — which used to make "the alarm loops"
|
|
490
|
+
indistinguishable from "the alarm fired once and stopped". Assert the flag when
|
|
491
|
+
that is the difference you care about:
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
const alarm = session.engine.audio.recent().find((e) => e.name === 'alarm');
|
|
495
|
+
expect(alarm?.loop).toBe(true);
|
|
496
|
+
```
|
|
497
|
+
|
|
433
498
|
This covers every path: `AudioPlayer.play()` on both the procedural and the
|
|
434
499
|
`src` route, `engine.music.play`/`crossfadeTo`, and `engine.sfx.startVoice`. It
|
|
435
500
|
records the INTENT to play — that the wiring fired — not that a speaker moved;
|
|
@@ -212,6 +212,35 @@ offsets that survive any canvas size: a `Label` at `[-16, 16]` under a
|
|
|
212
212
|
`top-right` layer hugs the corner everywhere. Misspelled anchors fail at load
|
|
213
213
|
listing the valid set. With a viewport design, UI coordinates are design px.
|
|
214
214
|
|
|
215
|
+
### A `UILayer` still SCALES. On a phone, use `HudLayer`
|
|
216
|
+
|
|
217
|
+
`UILayer` ignores the camera; it does not ignore the viewport. Its contents are
|
|
218
|
+
design pixels multiplied by the same scale as the world, and on a portrait phone
|
|
219
|
+
that scale is small:
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
1280×800 scale 1.333 a fontSize 15 Label paints at 20 device px
|
|
223
|
+
390×844 scale 0.406 the same Label paints at 6.1 device px
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Six device pixels is an unreadable smear, and it is the flagship 2D template's
|
|
227
|
+
control hint before this was fixed. The vertical framing goes the same way:
|
|
228
|
+
`design: [960, 540]` under `fit: "expand"` shows a portrait phone **2077 world
|
|
229
|
+
px** of height, so the play surface is a band with acres of empty sky.
|
|
230
|
+
|
|
231
|
+
**`HudLayer` and the `Ui*` widgets are DOM at their declared CSS pixels** and are
|
|
232
|
+
immune — which is why the engine's own volume sliders and touch controls stayed
|
|
233
|
+
legible in the same capture that reduced the template's HUD to a grey line. Use
|
|
234
|
+
them for anything a player has to READ:
|
|
235
|
+
|
|
236
|
+
| | |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| `Label` under `UILayer` | part of the picture — damage numbers, world-anchored callouts, art |
|
|
239
|
+
| `UiText` under `HudLayer` | part of the interface — score, hearts, hints, menus |
|
|
240
|
+
|
|
241
|
+
See `incanto-hud.md`. It costs nothing on desktop and it is the difference
|
|
242
|
+
between a readable and an unusable phone build.
|
|
243
|
+
|
|
215
244
|
## Patterns
|
|
216
245
|
|
|
217
246
|
- Group pickups/enemies (`"groups": ["coins"]`) and query `scene.tree.getNodesInGroup('coins')`.
|
|
@@ -485,3 +514,40 @@ which is why a wall jump was not awkward to write but impossible.
|
|
|
485
514
|
headless bot sessions and reports what could not be reached, which wires never
|
|
486
515
|
fired, and whether anything in the level can hurt the player. See
|
|
487
516
|
`incanto-playtesting.md`.
|
|
517
|
+
|
|
518
|
+
## When the boot FAILS: say so, or the bar sits at 100% forever
|
|
519
|
+
|
|
520
|
+
`createGame2D`/`createGame2D` reject like any promise, and the loading overlay
|
|
521
|
+
every starter shows is removed on the line *after* the await. So a boot that
|
|
522
|
+
fails leaves the bar at 100% with no text, and the reason in a console the
|
|
523
|
+
player will never open. Measured with WebGL denied on a pristine scaffold: the
|
|
524
|
+
overlay was still reading `Emberwood 100%` 8.6 seconds later.
|
|
525
|
+
|
|
526
|
+
One `.catch` fixes it, and every shipped template now carries it:
|
|
527
|
+
|
|
528
|
+
```ts
|
|
529
|
+
import { createGame2D, showBootFailure } from 'incanto/2d';
|
|
530
|
+
|
|
531
|
+
const game = await createGame2D({ canvas, scene: sceneJson }).catch((e) => {
|
|
532
|
+
showBootFailure(e); // paints the reason into #loading (or its own panel)
|
|
533
|
+
throw e;
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
document.querySelector('#loading')?.remove();
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**No WebGL is the case worth naming** — the 2D renderer is WebGL too. A machine
|
|
540
|
+
with acceleration off, a blocklisted driver, a locked-down or headless browser, or a page that already
|
|
541
|
+
holds too many contexts cannot make one, and three throws
|
|
542
|
+
`Error('Error creating WebGL context.')` with nothing to branch on. The engine
|
|
543
|
+
rethrows it as an `IncantoError` with code **`WEBGL_UNAVAILABLE`** and a message
|
|
544
|
+
naming the likely causes, so you can show a real screen:
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
import { isWebGLAvailable } from 'incanto/2d';
|
|
548
|
+
|
|
549
|
+
if (!isWebGLAvailable()) showMyOwnBrowserTooOldScreen(); // ask BEFORE booting
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
`isWebGLAvailable()` probes a throwaway canvas and never touches yours; it
|
|
553
|
+
returns `false` outside a browser.
|
|
@@ -320,8 +320,66 @@ atmosphere recipe below).
|
|
|
320
320
|
|
|
321
321
|
## Reference example
|
|
322
322
|
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
The smallest 3D scene that renders something you can see. Every line matters:
|
|
324
|
+
without the light it is black, without `current: true` the camera is a guess,
|
|
325
|
+
and without `ambient` the unlit faces are pitch dark.
|
|
326
|
+
|
|
327
|
+
```json
|
|
328
|
+
{
|
|
329
|
+
"format": 1,
|
|
330
|
+
"type": "scene",
|
|
331
|
+
"dimension": "3d",
|
|
332
|
+
"name": "Hello",
|
|
333
|
+
"environment": {
|
|
334
|
+
"sky": { "type": "atmosphere", "elevationDeg": 35, "azimuthDeg": 140 },
|
|
335
|
+
"ambient": { "color": "#ffffff", "intensity": 0.2 },
|
|
336
|
+
"shadows": true
|
|
337
|
+
},
|
|
338
|
+
"root": {
|
|
339
|
+
"name": "World",
|
|
340
|
+
"type": "Node3D",
|
|
341
|
+
"children": [
|
|
342
|
+
{ "name": "Sun", "type": "DirectionalLight3D",
|
|
343
|
+
"props": { "position": [6, 10, 4], "intensity": 2, "castShadow": true } },
|
|
344
|
+
{ "name": "Camera", "type": "Camera3D",
|
|
345
|
+
"props": { "position": [7, 6, 9], "rotation": [-26, 36, 0], "current": true } },
|
|
346
|
+
{ "name": "Ground", "type": "MeshInstance3D",
|
|
347
|
+
"props": { "mesh": "plane", "size": [40, 1, 40], "receiveShadow": true,
|
|
348
|
+
"material": { "color": "#6b8f3a", "roughness": 1 } } },
|
|
349
|
+
{ "name": "Pivot", "type": "Node3D", "script": { "name": "Spin" },
|
|
350
|
+
"children": [
|
|
351
|
+
{ "name": "Cube", "type": "MeshInstance3D",
|
|
352
|
+
"props": { "mesh": "box", "position": [2, 0.5, 0], "castShadow": true,
|
|
353
|
+
"material": { "color": "#e0563f", "roughness": 0.4 } } },
|
|
354
|
+
{ "name": "Ball", "type": "MeshInstance3D",
|
|
355
|
+
"props": { "mesh": "sphere", "size": [0.6, 0.6, 0.6], "position": [-2, 0.6, 0],
|
|
356
|
+
"castShadow": true,
|
|
357
|
+
"material": { "color": "#4f8fe0", "metalness": 0.6, "roughness": 0.2 } } },
|
|
358
|
+
{ "name": "Gem", "type": "MeshInstance3D",
|
|
359
|
+
"props": { "mesh": "gem", "size": [0.5, 0.5, 0.5], "position": [0, 0.8, 2],
|
|
360
|
+
"castShadow": true,
|
|
361
|
+
"material": { "color": "#ffd166", "flatShading": true, "roughness": 0.1 } } }
|
|
362
|
+
] }
|
|
363
|
+
]
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Rotating the PIVOT orbits all three children — no maths in the behaviour:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { Behavior } from 'incanto';
|
|
372
|
+
|
|
373
|
+
export class Spin extends Behavior {
|
|
374
|
+
override update(dt: number): void {
|
|
375
|
+
const node = this.node as unknown as { rotation: number[] };
|
|
376
|
+
node.rotation = [0, (node.rotation[1] ?? 0) + 40 * dt, 0];
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Check it before you open a browser: `bunx incanto-check` says the scene is legal
|
|
382
|
+
and lit, and `bunx incanto-verify` walks the whole ladder.
|
|
325
383
|
|
|
326
384
|
## Atmosphere (sky, fog, shadows, exposure)
|
|
327
385
|
|
|
@@ -648,3 +706,40 @@ It stubs unregistered behaviors (it measures geometry, not logic; a scene with
|
|
|
648
706
|
any custom behavior used to read as clean — measured 0 floating before
|
|
649
707
|
`registerBehavior`, 26 after) and throws when it cannot measure. A level built
|
|
650
708
|
from mesh floors has no terrain height to read: use a physics raycast instead.
|
|
709
|
+
|
|
710
|
+
## When the boot FAILS: say so, or the bar sits at 100% forever
|
|
711
|
+
|
|
712
|
+
`createGame2D`/`createGame3D` reject like any promise, and the loading overlay
|
|
713
|
+
every starter shows is removed on the line *after* the await. So a boot that
|
|
714
|
+
fails leaves the bar at 100% with no text, and the reason in a console the
|
|
715
|
+
player will never open. Measured with WebGL denied on a pristine scaffold: the
|
|
716
|
+
overlay was still reading `Emberwood 100%` 8.6 seconds later.
|
|
717
|
+
|
|
718
|
+
One `.catch` fixes it, and every shipped template now carries it:
|
|
719
|
+
|
|
720
|
+
```ts
|
|
721
|
+
import { createGame3D, showBootFailure } from 'incanto/3d';
|
|
722
|
+
|
|
723
|
+
const game = await createGame3D({ canvas, scene: sceneJson }).catch((e) => {
|
|
724
|
+
showBootFailure(e); // paints the reason into #loading (or its own panel)
|
|
725
|
+
throw e;
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
document.querySelector('#loading')?.remove();
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
**No WebGL is the case worth naming.** A machine with acceleration off, a
|
|
732
|
+
blocklisted driver, a locked-down or headless browser, or a page that already
|
|
733
|
+
holds too many contexts cannot make one, and three throws
|
|
734
|
+
`Error('Error creating WebGL context.')` with nothing to branch on. The engine
|
|
735
|
+
rethrows it as an `IncantoError` with code **`WEBGL_UNAVAILABLE`** and a message
|
|
736
|
+
naming the likely causes, so you can show a real screen:
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
import { isWebGLAvailable } from 'incanto/3d';
|
|
740
|
+
|
|
741
|
+
if (!isWebGLAvailable()) showMyOwnBrowserTooOldScreen(); // ask BEFORE booting
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
`isWebGLAvailable()` probes a throwaway canvas and never touches yours; it
|
|
745
|
+
returns `false` outside a browser.
|
|
@@ -71,7 +71,14 @@ warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
|
|
|
71
71
|
|
|
72
72
|
— which is the one failure that used to be completely silent (a prop path
|
|
73
73
|
resolves with `getNodeOrNull`, so the scene opens and the enemy just never
|
|
74
|
-
chases).
|
|
74
|
+
chases). That is for a behaviour the ENGINE ships: `incanto-check` reads scene
|
|
75
|
+
files and cannot load your TypeScript, so a `nodePath: true` prop on a
|
|
76
|
+
behaviour of YOUR OWN is invisible to it. `auditScene` from `incanto/test`
|
|
77
|
+
gives the identical sentence once your behaviours are registered — which is why
|
|
78
|
+
every scaffolded `verify.ts` calls it before `runScript`, and why a
|
|
79
|
+
hand-written harness that skips it loses this check.
|
|
80
|
+
|
|
81
|
+
`Chase.target` and `FollowCamera.target` are `required`: an EMPTY one
|
|
75
82
|
is a load error naming the node, not a surprise in the browser.
|
|
76
83
|
|
|
77
84
|
**Never write `/root/<RootName>/...`.** If your root node is named `Game`, the
|
|
@@ -1189,6 +1196,16 @@ Multi-scene games: `flow.goToScene(nextSceneJson, { fadeSeconds: 0.4 })`
|
|
|
1189
1196
|
fades to black, swaps, fades back (headless = instant). Title → level →
|
|
1190
1197
|
next level is three JSON files and this one call.
|
|
1191
1198
|
|
|
1199
|
+
**A swap builds the physics world the incoming scene needs.** `physics: 'auto'`
|
|
1200
|
+
can only look at the scene the game BOOTS with, and a title screen has no
|
|
1201
|
+
bodies — so this used to leave the level with no physics at all: it rendered
|
|
1202
|
+
perfectly, nothing fell, nothing collided, nothing could be collected or hurt.
|
|
1203
|
+
`createGame2D`/`createGame3D` now hand the engine a way to build one, and the
|
|
1204
|
+
swap asks. It is asynchronous (Rapier is WASM), so the first frames after a
|
|
1205
|
+
swap into a physics scene run unsimulated — imperceptible, and the alternative
|
|
1206
|
+
was a game that never simulated. `physics: false` stays off across swaps, and a
|
|
1207
|
+
scene with no bodies still pays for nothing.
|
|
1208
|
+
|
|
1192
1209
|
## DayNight (live 24-hour cycle)
|
|
1193
1210
|
|
|
1194
1211
|
Drives the scene `environment` (needs an atmosphere sky): sun elevation/
|
|
@@ -1211,7 +1228,7 @@ spawners, music, or monster aggression to it. The `hour` field (0-24) is
|
|
|
1211
1228
|
writable: jump to a time of day in one assignment. For one-off look changes
|
|
1212
1229
|
use `setEnvironment3D(engine, patch)` from `incanto/3d` directly.
|
|
1213
1230
|
|
|
1214
|
-
## Boot loading overlay
|
|
1231
|
+
## Boot loading overlay
|
|
1215
1232
|
|
|
1216
1233
|
```ts
|
|
1217
1234
|
import { preloadSceneAssets } from 'incanto';
|
|
@@ -1221,6 +1238,33 @@ await preloadSceneAssets(sceneJson.assets); // black screen + bar + %, auto-re
|
|
|
1221
1238
|
Replaces the ~125-line hand-rolled loading screen; `createLoadingOverlay()`
|
|
1222
1239
|
gives you the raw overlay when you preload something else.
|
|
1223
1240
|
|
|
1241
|
+
## Where a node actually IS (`position` is parent-relative)
|
|
1242
|
+
|
|
1243
|
+
`node.position` is the offset from its PARENT, not the world. A scene nests
|
|
1244
|
+
freely — `Terrain/Clouds/Cloud1/Puff1` four deep is what the engine's own
|
|
1245
|
+
generator writes — so a distance check reading `position` on a nested node
|
|
1246
|
+
measures from the wrong origin and says nothing about it. Measured on a crate
|
|
1247
|
+
parented to a rock: `position` is `[0, 3.02, 0]` and the world point is
|
|
1248
|
+
`[13, 14.31, 80.18]` — **82 m apart**.
|
|
1249
|
+
|
|
1250
|
+
```ts
|
|
1251
|
+
import { localFromWorld, worldPosition } from 'incanto/gameplay';
|
|
1252
|
+
|
|
1253
|
+
const here = worldPosition(crate); // [13, 14.31, 80.18]
|
|
1254
|
+
crate.position = localFromWorld(crate, [0, 20, 0]); // put it at world [0,20,0]
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
Both work headless and in both dimensions — arity comes from the node (2 for a
|
|
1258
|
+
2D node, 3 for a 3D one), and an ancestor with no `position` contributes
|
|
1259
|
+
nothing. Do NOT reach for `_ensureObject3D().matrixWorld`: it is internal, and
|
|
1260
|
+
in a headless verify script there is no renderer to parent the object, so it
|
|
1261
|
+
hands back the LOCAL position with no warning.
|
|
1262
|
+
|
|
1263
|
+
**Translation only.** They sum ancestor positions and ignore ancestor rotation
|
|
1264
|
+
and scale — the same contract physics already uses (`worldPosition2D` /
|
|
1265
|
+
`worldPosition3D`) and that `applyGroundSnap` assumes. A node under a ROTATING
|
|
1266
|
+
parent needs the renderer's matrix, not this.
|
|
1267
|
+
|
|
1224
1268
|
## Persistence (high scores, unlocks, settings)
|
|
1225
1269
|
|
|
1226
1270
|
```ts
|
|
@@ -297,6 +297,7 @@ Signals: `movementStateChanged(state)`
|
|
|
297
297
|
| `pitchMin` | `-1.3` | number |
|
|
298
298
|
| `pitchMax` | `1.5` | number |
|
|
299
299
|
| `cameraCollision` | `true` | boolean |
|
|
300
|
+
| `platformCarry` | `true` | boolean |
|
|
300
301
|
| `turnSpeed` | `100` | number |
|
|
301
302
|
| `camLerp` | `25` | number |
|
|
302
303
|
| `moveAction` | `"move"` | string |
|
|
@@ -479,6 +479,29 @@ rotating discs. It is what makes a floating-island game work.
|
|
|
479
479
|
Author it the obvious way: move the platform's `position` (a behavior, a
|
|
480
480
|
`PathFollow`, an `Oscillate`). Nothing else to declare.
|
|
481
481
|
|
|
482
|
+
**The 3D PLAYER is a different rig, and it needs one prop.** A
|
|
483
|
+
`CharacterController3D` must sit under a dynamic `RigidBody3D` (hard error
|
|
484
|
+
otherwise), not a `CharacterBody3D` — the character bodies in a 3D game are its
|
|
485
|
+
enemies and NPCs. The controller steers toward a target velocity every fixed
|
|
486
|
+
step, so pressing nothing means "target zero", which in world space is a brake
|
|
487
|
+
aimed at the platform's own motion. `platformCarry` (default **true**) makes
|
|
488
|
+
that target relative to the floor instead. Measured on a 400 m deck moving
|
|
489
|
+
4 m/s, rider pressing nothing: **95.9%** of the travel kept, **0.0%** with
|
|
490
|
+
`platformCarry: false`.
|
|
491
|
+
|
|
492
|
+
```json
|
|
493
|
+
{ "name": "Ctl", "type": "CharacterController3D",
|
|
494
|
+
"props": { "view": "free", "platformCarry": false } }
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Turn it off for a conveyor you want to be scenery. Vertical carry needs no prop
|
|
498
|
+
either way — the hover spring rides whatever surface is under it, which is why
|
|
499
|
+
a 3D elevator always looked right while a conveyor did not.
|
|
500
|
+
|
|
501
|
+
The carry comes from the platform's POSITION DELTA, not its `linearVelocity`,
|
|
502
|
+
because writing `position` calls `setTranslation` — a teleport that transfers no
|
|
503
|
+
momentum to anything. Both authoring styles work.
|
|
504
|
+
|
|
482
505
|
The carry stops the moment the character is no longer grounded on it, so walking
|
|
483
506
|
off an edge or jumping is not "sticky", and it is vertical as well as horizontal
|
|
484
507
|
— an elevator lifts you.
|
|
@@ -253,6 +253,33 @@ that DOES serialize, on a node with no uid, is state that goes nowhere.
|
|
|
253
253
|
(`Health`, `ScoreKeeper`, `Collector`); a scene file cannot be asked whether
|
|
254
254
|
YOUR behavior serializes, so this walks the live tree and names those too.
|
|
255
255
|
|
|
256
|
+
## When the browser will not store anything
|
|
257
|
+
|
|
258
|
+
A private window, storage disabled, or an exhausted quota: `localStorage`
|
|
259
|
+
throws, and the store falls back to memory. **That fallback is right** —
|
|
260
|
+
refusing to save would be worse — but everything in the session still reads
|
|
261
|
+
healthy, so the player only finds out by reloading and losing the run. Measured
|
|
262
|
+
under Safari-private conditions: `set('highScore', 4200)` then `get(...)`
|
|
263
|
+
returned 4200, with zero warnings and no way to ask.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
if (!slots.persistent) {
|
|
267
|
+
banner.show('This browser will not keep your progress — private window?', { seconds: 6 });
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`SaveSlots.persistent` (and `SaveStore.persistent`) is `false` whenever writes
|
|
272
|
+
live only as long as the tab. The engine also says it once per namespace: on the
|
|
273
|
+
console, and — from `SavePoint` — through `engine.log`, so `incanto-logs` and
|
|
274
|
+
the `says` rung of `incanto-verify` see it too.
|
|
275
|
+
|
|
276
|
+
`SavePoint` still emits `saved(slot)` in that state, deliberately: it did save,
|
|
277
|
+
for as long as the page is open, and a Continue button that never lights up
|
|
278
|
+
would be a second bug. Ask `persistent` before promising the player anything.
|
|
279
|
+
|
|
280
|
+
Headless — tests, verify scripts, SSR — `persistent` is `false` and nothing is
|
|
281
|
+
logged: in-memory is the design there, not a failure.
|
|
282
|
+
|
|
256
283
|
## Saving from the scene — `SavePoint`
|
|
257
284
|
|
|
258
285
|
*When* to save is a design decision (checkpoint, level end, on quit) and only
|
|
@@ -26,6 +26,7 @@ wiring, and composition are all in the file. TS is for *behavior only* (attached
|
|
|
26
26
|
"constants": { "UI": 1000, "Background": -100 },
|
|
27
27
|
"input": { "<action>": { "...": "...", "touch": "joystick|button (optional — mobile on-screen controls)" } },
|
|
28
28
|
"multiplayer": { "room": "auto" },
|
|
29
|
+
"fragment": false,
|
|
29
30
|
"root": { "name": "Level1", "type": "Node", "children": [] },
|
|
30
31
|
"connections": []
|
|
31
32
|
}
|
|
@@ -33,6 +34,13 @@ wiring, and composition are all in the file. TS is for *behavior only* (attached
|
|
|
33
34
|
|
|
34
35
|
- `format` MUST be `1`. `type` MUST be `"scene"`. `name` non-empty.
|
|
35
36
|
- `dimension` is optional: `"2d"` or `"3d"` only.
|
|
37
|
+
- `fragment` (optional, default `false`) says this file is a PIECE of another
|
|
38
|
+
scene — a spawner's prefab, a sub-scene something instances — and is never
|
|
39
|
+
rendered on its own. It changes nothing at runtime; it tells the checker to
|
|
40
|
+
stop asking a prefab where its camera and its sun are. Set it on any scene
|
|
41
|
+
you load from TypeScript and hand to a spawner. (A scene another scene
|
|
42
|
+
embeds with `instance` needs no key — `incanto-check` walks the project and
|
|
43
|
+
can see that for itself.)
|
|
36
44
|
- `viewport` (optional) makes scene JSON own responsive layout: author the world
|
|
37
45
|
in fixed `design` pixels (`[width, height]`, positive numbers) and the renderer
|
|
38
46
|
maps them onto any canvas size. `fit`: `"expand"` (design rect always fully
|
|
@@ -158,7 +166,10 @@ level 1's layers.
|
|
|
158
166
|
## Parents and children
|
|
159
167
|
|
|
160
168
|
EVERY node type can hold children — `children` is universal, so any node works
|
|
161
|
-
as a grouping container.
|
|
169
|
+
as a grouping container. **A child's `position` is relative to its parent**, so
|
|
170
|
+
nesting is also how you move a group: shift the parent and everything under it
|
|
171
|
+
follows. From code, `worldPosition(node)` (`incanto/gameplay`) is where a nested
|
|
172
|
+
node actually is; `node.position` is the local offset. The inverse is not true: some types demand a SPECIFIC
|
|
162
173
|
parent and are therefore also invalid as the root. Today's rule:
|
|
163
174
|
|
|
164
175
|
- `CharacterController2D` must be a direct child of a `CharacterBody2D` —
|
|
@@ -116,6 +116,16 @@ The sections below are the same rungs one at a time, for when you need the detai
|
|
|
116
116
|
|
|
117
117
|
## 1. After EVERY scene edit: `incanto-check`
|
|
118
118
|
|
|
119
|
+
```bash
|
|
120
|
+
bunx incanto-check # structure, assets, semantics
|
|
121
|
+
bunx incanto-check --behaviors src/behaviors.ts --strict-behaviors
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`--strict-behaviors` makes a `script.name` that resolves nowhere a FAILURE
|
|
125
|
+
instead of a stub — and it needs `--behaviors` to know what you registered.
|
|
126
|
+
Without it, on any project with a behaviour of its own, it could only ever fail:
|
|
127
|
+
`Unknown behavior 'Hunter' … Did you forget registerBehavior?`
|
|
128
|
+
|
|
119
129
|
```bash
|
|
120
130
|
bunx incanto-check # validates every *.scene.json under cwd
|
|
121
131
|
bunx incanto-check --json # machine-readable (code + details.path/prop)
|
|
@@ -637,8 +647,9 @@ the path, then **▶ play ▸ ⤢ run as the game** and the page is the game aga
|
|
|
637
647
|
running your edit. `game.openEditor()` is the same switch from code.
|
|
638
648
|
|
|
639
649
|
It follows `debug` (so a build with the overlay has it) and loads lazily, so a
|
|
640
|
-
game that never opens it never downloads it. `editor: false` opts out
|
|
641
|
-
`editor: { save }` adds the editor's save
|
|
650
|
+
game that never opens it never downloads it. `editor: false` opts out at
|
|
651
|
+
RUNTIME — the button is absent — and `editor: { save }` adds the editor's save
|
|
652
|
+
button, wired to your own writer:
|
|
642
653
|
|
|
643
654
|
```ts
|
|
644
655
|
createGame3D({
|
|
@@ -660,6 +671,89 @@ overlay is a lazily-imported chunk — it stays out of the main bundle (no size
|
|
|
660
671
|
cost), but it still ships in the build, so that runtime `debug` flag is the only
|
|
661
672
|
thing gating it: keep it `false` (or dev-gated) in anything you publish.
|
|
662
673
|
|
|
674
|
+
## 9. Verify the thing you SHIP, not just the thing you run
|
|
675
|
+
|
|
676
|
+
Everything above measures a dev server. The artifact a player gets is
|
|
677
|
+
`bun run build` — a bundled, minified `dist/` with different asset paths and a
|
|
678
|
+
different module graph — and the boot path is the only code a BUILD adds over a
|
|
679
|
+
SCENE. Two of the nastiest defects this engine has had lived exactly there: a
|
|
680
|
+
`main.ts` whose scene swap left the game with no physics, and a boot that threw
|
|
681
|
+
behind a loading overlay that never came down.
|
|
682
|
+
|
|
683
|
+
`incanto-frame` and `incanto-logs` ask a dev server, and the dev server asks the
|
|
684
|
+
page over vite's HMR channel. A build has no HMR, so they used to answer:
|
|
685
|
+
|
|
686
|
+
```
|
|
687
|
+
? draws — no incanto dev server found.
|
|
688
|
+
? says — no incanto dev server found.
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
**`incanto-serve` closes that.** It serves your build as plain static files —
|
|
692
|
+
no bundler, no transform, byte-for-byte what you deploy — and adds only the
|
|
693
|
+
`/__incanto/*` endpoints the CLIs already speak, plus one inline script injected
|
|
694
|
+
into `index.html` at serve time. Nothing is written to disk, so what you measure
|
|
695
|
+
is what you ship.
|
|
696
|
+
|
|
697
|
+
```bash
|
|
698
|
+
bun run build # the artifact
|
|
699
|
+
bunx incanto-serve # ./dist on :4180 (or: incanto-serve build --port 5050)
|
|
700
|
+
# open http://127.0.0.1:4180/ , then:
|
|
701
|
+
bunx incanto-verify # draws and says now measure the BUILT game
|
|
702
|
+
bunx incanto-logs # what the built page is complaining about
|
|
703
|
+
bunx incanto-frame --out shipped.png
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
**Ship-check list**, in the order things actually break:
|
|
707
|
+
|
|
708
|
+
1. **`bun run build` at all.** Dev-only code (`import.meta.env.DEV`, a plugin
|
|
709
|
+
that is `apply: 'serve'`) can compile and then do nothing.
|
|
710
|
+
2. **Open the built page.** A blank screen with a console error is the common
|
|
711
|
+
failure; `incanto-logs` reads it without you opening devtools.
|
|
712
|
+
3. **`incanto-verify` against `incanto-serve`.** `draws` catches an asset base
|
|
713
|
+
path that survived dev and broke under a subdirectory deploy; `says` catches
|
|
714
|
+
the 404s.
|
|
715
|
+
4. **A phone-sized window.** See `incanto-web-integration.md` — a HUD that is
|
|
716
|
+
legible at 1280×800 can be 6 px tall at 390×844.
|
|
717
|
+
5. **A second load.** Saves, settings and unlocks come back; a private window
|
|
718
|
+
answers `slots.persistent === false` (see `incanto-save-slots.md`).
|
|
719
|
+
|
|
720
|
+
The one thing `incanto-serve` is not is a production server: it is a local tool
|
|
721
|
+
for looking at a build, on `127.0.0.1` by default.
|
|
722
|
+
|
|
723
|
+
### The editor is in your build, even in a 2D game
|
|
724
|
+
|
|
725
|
+
`editor: false` is a runtime option and **cannot remove bytes from a bundle**.
|
|
726
|
+
The editor chunk is lazy — a game that never opens it never downloads it — but
|
|
727
|
+
it is in `dist/`, and it imports `incanto/3d`, which imports rapier3d. Measured
|
|
728
|
+
on a pristine `platformer-2d` scaffold:
|
|
729
|
+
|
|
730
|
+
```
|
|
731
|
+
2236503 assets/rapier-*.js rapier3d ← a 2D game can never execute this
|
|
732
|
+
1692278 assets/rapier-*.js rapier2d ← the one it uses
|
|
733
|
+
471186 assets/3d-*.js the 3D adapter
|
|
734
|
+
─────────
|
|
735
|
+
5979588 total
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
A build-time decision needs a build-time lever:
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
import { incantoScenes, incantoTrim } from 'incanto/vite';
|
|
742
|
+
export default defineConfig({
|
|
743
|
+
plugins: [incantoScenes(), incantoTrim({ editor: false })],
|
|
744
|
+
});
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
```
|
|
748
|
+
5979588 → 2769972 bytes 53.7% smaller, same game
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Build only: the dev server keeps the editor, so `☰ debug ▸ ✎ edit this scene`
|
|
752
|
+
works the whole time you are making the game and is simply absent from what you
|
|
753
|
+
ship. Opening it in a trimmed build says so in one sentence rather than failing
|
|
754
|
+
obscurely. Reach for this when deploy size matters — a CDN, a jam upload, an
|
|
755
|
+
agent8 zip — and leave it off otherwise.
|
|
756
|
+
|
|
663
757
|
## 8. Debugging signals
|
|
664
758
|
|
|
665
759
|
- `this.log.info(...)` in behaviors — shows up in `runScript().logs` (and the
|