incanto 0.1.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 (138) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.md +88 -0
  4. package/assets/audio/attacked.mp3 +0 -0
  5. package/assets/audio/explosion.mp3 +0 -0
  6. package/assets/audio/gold_loot.mp3 +0 -0
  7. package/assets/audio/heal.mp3 +0 -0
  8. package/assets/audio/hit_metal_bang.mp3 +0 -0
  9. package/assets/audio/ice_spear.mp3 +0 -0
  10. package/assets/audio/monster_died.mp3 +0 -0
  11. package/assets/audio/slash.mp3 +0 -0
  12. package/assets/audio/smite.mp3 +0 -0
  13. package/assets/audio/spells_cast.mp3 +0 -0
  14. package/assets/audio/ui_click.wav +0 -0
  15. package/assets/audio/walk.mp3 +0 -0
  16. package/assets/catalog.json +390 -0
  17. package/assets/characters/2dbasic.json +41 -0
  18. package/assets/characters/2dbasic.png +0 -0
  19. package/assets/characters/ghost.json +46 -0
  20. package/assets/characters/ghost.png +0 -0
  21. package/assets/characters/goblin.json +40 -0
  22. package/assets/characters/goblin.png +0 -0
  23. package/assets/characters/medieval-knight.json +41 -0
  24. package/assets/characters/medieval-knight.png +0 -0
  25. package/assets/effects/swoosh.png +0 -0
  26. package/assets/items/box.png +0 -0
  27. package/assets/items/buff_potion.png +0 -0
  28. package/assets/items/coin.png +0 -0
  29. package/assets/items/gem.png +0 -0
  30. package/assets/items/gold.png +0 -0
  31. package/assets/items/hp_potion.png +0 -0
  32. package/assets/items/locked_item_box.png +0 -0
  33. package/assets/items/map.png +0 -0
  34. package/assets/items/resurrection_potion.png +0 -0
  35. package/assets/items/super_box.png +0 -0
  36. package/assets/items/trap.png +0 -0
  37. package/assets/tiles/floor00.jpg +0 -0
  38. package/assets/tiles/minecraft-tiles.png +0 -0
  39. package/assets/tiles/wall00.jpg +0 -0
  40. package/assets/vegetation/ash_color.png +0 -0
  41. package/assets/vegetation/aspen_color.png +0 -0
  42. package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
  43. package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
  44. package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
  45. package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
  46. package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
  47. package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
  48. package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
  49. package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
  50. package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
  51. package/assets/vegetation/ground/dirt_color.jpg +0 -0
  52. package/assets/vegetation/ground/dirt_normal.jpg +0 -0
  53. package/assets/vegetation/ground/grass.jpg +0 -0
  54. package/assets/vegetation/oak_color.png +0 -0
  55. package/assets/vegetation/pine_color.png +0 -0
  56. package/bin/incanto-assets.mjs +107 -0
  57. package/bin/incanto-check.mjs +107 -0
  58. package/bin/incanto-editor.mjs +343 -0
  59. package/bin/incanto-env.mjs +144 -0
  60. package/bin/incanto-model.mjs +296 -0
  61. package/bin/incanto-play.mjs +219 -0
  62. package/bin/incanto-skills.mjs +71 -0
  63. package/dist/2d.d.ts +642 -0
  64. package/dist/2d.js +44 -0
  65. package/dist/3d.d.ts +1860 -0
  66. package/dist/3d.js +5 -0
  67. package/dist/agent8-DzU2fFyH.js +129 -0
  68. package/dist/audio-player-DqUR3XFs.d.ts +110 -0
  69. package/dist/behavior-BAQq7HGM.d.ts +851 -0
  70. package/dist/create-game-BdjpTHrW.js +1725 -0
  71. package/dist/create-game-CZHROKcT.js +527 -0
  72. package/dist/debug-draw-CZmOYjL2.js +13 -0
  73. package/dist/debug.d.ts +66 -0
  74. package/dist/debug.js +658 -0
  75. package/dist/duplicate-DP2WPYom.js +22 -0
  76. package/dist/env.d.ts +430 -0
  77. package/dist/env.js +3152 -0
  78. package/dist/errors-BMFaY68Q.d.ts +33 -0
  79. package/dist/errors-BpWbnbb_.js +13 -0
  80. package/dist/gameplay-Ccruc3Wd.js +1501 -0
  81. package/dist/gameplay.d.ts +543 -0
  82. package/dist/gameplay.js +2 -0
  83. package/dist/heightmap-CroQPEER.js +185 -0
  84. package/dist/index.d.ts +305 -0
  85. package/dist/index.js +62 -0
  86. package/dist/json-BLk7H2Qa.js +30 -0
  87. package/dist/loader-CGs_G-r0.js +919 -0
  88. package/dist/loader-Mo0KghCv.d.ts +41 -0
  89. package/dist/net.d.ts +427 -0
  90. package/dist/net.js +772 -0
  91. package/dist/noise-CGUMx44x.js +82 -0
  92. package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
  93. package/dist/particle-sim-DYuSUxvK.js +1319 -0
  94. package/dist/physics-2d-KuMWPTf6.js +288 -0
  95. package/dist/physics-3d-Dl67vOLT.js +434 -0
  96. package/dist/react.d.ts +65 -0
  97. package/dist/react.js +209 -0
  98. package/dist/register-BuUV1_KB.js +561 -0
  99. package/dist/register-CNlYAS1_.js +10634 -0
  100. package/dist/register-DPEV9_9t.js +851 -0
  101. package/dist/register-Dasmnurl.js +374 -0
  102. package/dist/registry-BVJ2HbCn.js +132 -0
  103. package/dist/rng-DP-SR7eg.js +38 -0
  104. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  105. package/dist/schema-CcoWb32N.d.ts +104 -0
  106. package/dist/test.d.ts +158 -0
  107. package/dist/test.js +275 -0
  108. package/dist/touch-031PxtCR.js +208 -0
  109. package/dist/vite.d.ts +26 -0
  110. package/dist/vite.js +57 -0
  111. package/editor/assets/GameServer-C56iOUgF.js +1 -0
  112. package/editor/assets/agent8-Bp7QFI7v.js +1 -0
  113. package/editor/assets/index-DF3tMeKJ.css +1 -0
  114. package/editor/assets/index-Dl2pjA8e.js +7365 -0
  115. package/editor/assets/rapier-CEuLKeCu.js +1 -0
  116. package/editor/assets/rapier-DE6a0vmv.js +1 -0
  117. package/editor/index.html +169 -0
  118. package/package.json +97 -0
  119. package/schemas/scene.schema.json +4254 -0
  120. package/skills/README.md +9 -0
  121. package/skills/incanto-3d-character.md +229 -0
  122. package/skills/incanto-3d-models.md +151 -0
  123. package/skills/incanto-assets.md +118 -0
  124. package/skills/incanto-audio.md +309 -0
  125. package/skills/incanto-behaviors-and-scripts.md +169 -0
  126. package/skills/incanto-building-2d-games.md +242 -0
  127. package/skills/incanto-building-3d-games.md +245 -0
  128. package/skills/incanto-editor.md +163 -0
  129. package/skills/incanto-environment.md +743 -0
  130. package/skills/incanto-gameplay-behaviors.md +707 -0
  131. package/skills/incanto-multiplayer.md +264 -0
  132. package/skills/incanto-node-reference.md +797 -0
  133. package/skills/incanto-physics-and-input.md +164 -0
  134. package/skills/incanto-scene-json-authoring.md +325 -0
  135. package/skills/incanto-verifying-your-game.md +191 -0
  136. package/skills/incanto-web-integration.md +96 -0
  137. package/templates/agent8-server.js +84 -0
  138. package/templates/agent8-server.ts +138 -0
@@ -0,0 +1,309 @@
1
+ ---
2
+ name: incanto-audio
3
+ description: Sound in Incanto — zero-asset PROCEDURAL SFX presets (coin/jump/hurt/explosion/laser/win…, the art-free audio analog of particle presets), the AudioPlayer node (preset vs src file, autoplay + browser gesture-unlock, the finished signal, 3D spatial/positional audio), the music manager (crossfade/loop background music), global volume buses (master/sfx/music + mute), and the built-in audio catalog of real sound files. Use whenever a game needs sound, sfx, audio effects, music, spatial/positional audio, or volume control.
4
+ ---
5
+
6
+ # Audio — sound, SFX, and music
7
+
8
+ > Shipped inside the `incanto` npm package — this document always matches the
9
+ > installed engine version. Sibling skills live in `node_modules/incanto/skills/`.
10
+
11
+ Sound comes from the **`AudioPlayer`** node. It has two modes:
12
+
13
+ 1. **`preset`** — a built-in **procedural SFX** synthesized on the fly. **Zero
14
+ asset bytes, instant, deterministic.** This is the headline path: the audio
15
+ analog of the `Particles2D` presets / art-free prototyping. Reach for this
16
+ first — a working game can ship sound with no files at all.
17
+ 2. **`src`** — play an audio file (URL). Good for music and longer clips, and
18
+ for the curated built-in sounds (§4). Used when `preset` is `"custom"`.
19
+
20
+ All volume routes through global **buses** (§3): `master × (sfx|music) × volume`.
21
+
22
+ `AudioPlayer` is headless-safe — with no audio backend (node, the verify VM)
23
+ every method is a silent no-op, so scenes load and simulate everywhere.
24
+
25
+ ---
26
+
27
+ ## 1. Procedural SFX presets — the zero-asset path (lead with this)
28
+
29
+ Set `preset` to one of the names below and call `play()`. No files, no loading.
30
+
31
+ ```jsonc
32
+ { "type": "AudioPlayer", "name": "Coin", "props": { "preset": "coin" } }
33
+ ```
34
+
35
+ ```ts
36
+ const coin = scene.tree.root.getNode('Coin'); // an AudioPlayer
37
+ coin.play(); // synthesizes + plays instantly; rapid calls OVERLAP (no cutoff)
38
+ ```
39
+
40
+ ### The preset set
41
+
42
+ | preset | sound it makes | typical use |
43
+ |-------------|-------------------------------------------------|------------------------|
44
+ | `coin` | bright ascending chime | collect a coin |
45
+ | `pickup` | soft two-tone blip | generic item pickup |
46
+ | `jump` | rising whoop | jump |
47
+ | `hurt` | harsh downward buzz | take damage |
48
+ | `hit` | short noisy thud | impact / melee hit |
49
+ | `explosion` | big falling-pitch boom | explosion (+particles) |
50
+ | `powerup` | cheerful rising sweep | power-up / level up |
51
+ | `laser` | zappy descending beam | laser / energy shot |
52
+ | `shoot` | punchy square shot | gunfire / projectile |
53
+ | `blip` | tiny neutral tick | UI feedback |
54
+ | `select` | confident two-step | menu select / confirm |
55
+ | `step` | soft muted noise tap | footstep |
56
+ | `win` | happy fanfare rise | victory |
57
+ | `lose` | sad descending tone | game over |
58
+
59
+ (Authoritative list: `incanto-node-reference.md` → `AudioPlayer.preset` options,
60
+ or `import { SFX_PRESET_NAMES } from 'incanto'`.)
61
+
62
+ ### Variation: `pitch` + `seed`
63
+
64
+ Repeated identical SFX feel robotic. Vary them for free:
65
+
66
+ - **`pitch`** (default 1) — multiplies the frequency. `0.8` lower, `1.4` higher.
67
+ - **`seed`** (default 0) — varies the noise in noisy presets (`explosion`,
68
+ `hit`, `step`). Same seed → byte-identical sound (deterministic, replay-safe).
69
+
70
+ ```jsonc
71
+ { "type": "AudioPlayer", "name": "Step",
72
+ "props": { "preset": "step", "pitch": 1.1, "seed": 3 } }
73
+ ```
74
+
75
+ To re-roll variation at runtime, set `seed` before each `play()`:
76
+
77
+ ```ts
78
+ step.seed = (step.seed + 1) | 0; step.play();
79
+ ```
80
+
81
+ ### Firing presets from gameplay
82
+
83
+ Wire them through scene `connections` — e.g. play `coin` when a Collector picks
84
+ something up, `hurt` when Health takes damage:
85
+
86
+ ```jsonc
87
+ "connections": [
88
+ { "signal": "collected", "from": "Player/Collector", "to": "Coin", "handler": "play" },
89
+ { "signal": "damaged", "from": "Player/Health", "to": "Hurt", "handler": "play" }
90
+ ]
91
+ ```
92
+
93
+ `play` is a method on `AudioPlayer`, so a connection can target it directly.
94
+
95
+ > Procedural SFX use **WebAudio** (low-latency, overlap-friendly) — not the
96
+ > HTMLAudio element. WebAudio is a browser API behind a headless guard, so it's
97
+ > a no-op in the verify VM. Like all audio it needs a user gesture first; the
98
+ > same unlock listener that retries `src` players also resumes the SFX context
99
+ > (see §2).
100
+
101
+ ---
102
+
103
+ ## 2. The AudioPlayer node (preset vs src file)
104
+
105
+ ```jsonc
106
+ { "type": "AudioPlayer", "name": "Bgm", "props": {
107
+ "src": "incanto/assets/audio/...mp3", // file URL (preset = "custom")
108
+ "preset": "custom", // or a preset name → ignores src
109
+ "volume": 1, // 0..1, before bus gain
110
+ "bus": "sfx", // "sfx" (default) or "music"
111
+ "loop": false, // loop the src clip (music)
112
+ "autoplay": false // play on first frame in the tree
113
+ } }
114
+ ```
115
+
116
+ Methods: **`play()`**, **`stop()`**. Signal: **`finished`** (fires when a
117
+ non-looping `src` clip ends — connect it to clean up or chain sounds). Procedural
118
+ presets are fire-and-forget one-shots: they do not emit `finished`.
119
+
120
+ ### Autoplay + the browser gesture-unlock
121
+
122
+ Browsers block audio before the first user gesture. `autoplay: true` (or any
123
+ early `play()`) that gets blocked is marked pending; **`createGame2D` /
124
+ `createGame3D` automatically retry every pending player AND resume the WebAudio
125
+ SFX context on the first pointerdown/keydown** — you don't wire anything. A
126
+ blocked play is not an error; it just waits for the gesture.
127
+
128
+ ### Music: loop a `src` clip on the music bus
129
+
130
+ ```jsonc
131
+ { "type": "AudioPlayer", "name": "Music", "props": {
132
+ "src": "incanto/assets/audio/...mp3",
133
+ "loop": true, "autoplay": true, "bus": "music", "volume": 0.6 } }
134
+ ```
135
+
136
+ > For crossfading between tracks (e.g. exploration → boss music), use the **music
137
+ > manager** (§5) instead of an `AudioPlayer` — it owns a single track and ramps.
138
+
139
+ ---
140
+
141
+ ## 2b. Spatial (3D positional) audio
142
+
143
+ Set **`spatial: true`** on an `AudioPlayer` in a **3D scene** and the sound pans
144
+ (left/right) and attenuates by **distance** — the emitter is the node's world
145
+ position, the **listener is the active `Camera3D`** (the one marked `current`).
146
+ The 3D adapter feeds the emitter + listener pose to the panner every frame, so
147
+ moving the emitter or the camera updates the sound live. Works for BOTH paths
148
+ (procedural `preset` and a `src` file).
149
+
150
+ ```jsonc
151
+ // Attach under a moving Node3D — it emits from THAT node's world position.
152
+ { "type": "Node3D", "name": "Enemy", "props": { "position": [8, 0, -3] },
153
+ "children": [
154
+ { "type": "AudioPlayer", "name": "Footsteps", "props": {
155
+ "preset": "step",
156
+ "spatial": true, // ← turn on 3D positional audio
157
+ "refDistance": 2, // full volume within 2 units
158
+ "maxDistance": 40, // inaudible past 40 units
159
+ "rolloff": "inverse" // "inverse" | "linear" | "exponential"
160
+ } }
161
+ ] }
162
+ ```
163
+
164
+ | prop | default | meaning |
165
+ |---------------|-------------|--------------------------------------------------------|
166
+ | `spatial` | `false` | off = ordinary non-positional sound (back-compat) |
167
+ | `refDistance` | `1` | distance at which gain is full; closer never louder |
168
+ | `maxDistance` | `50` | distance past which gain stops falling |
169
+ | `rolloff` | `"inverse"` | attenuation curve (WebAudio PannerNode distance model) |
170
+
171
+ - **`inverse`** (default): `ref / (ref + d−ref)` — natural-sounding 1/d falloff.
172
+ - **`linear`**: straight ramp to ~0 at `maxDistance`.
173
+ - **`exponential`**: steeper near falloff.
174
+
175
+ Under the hood spatial audio uses a WebAudio **`PannerNode` (HRTF)** with the
176
+ listener driven by the camera's world position + orientation. The pure
177
+ distance-gain + pan-sign math (`spatialGain`, `spatialPan`, exported) is what the
178
+ non-WebAudio paths use, so headless gameplay is unaffected.
179
+
180
+ > **2D scenes:** spatial is currently **ignored** (no listener is fed) — a 2D
181
+ > sound plays non-positionally regardless of `spatial`. Pan a 2D sound yourself
182
+ > from the camera-relative x-offset if you need it. Spatial is a 3D feature.
183
+
184
+ > **Headless / verify VM:** spatial is a no-op like all audio — `play()` won't
185
+ > throw and your gameplay logic is unaffected.
186
+
187
+ ---
188
+
189
+ ## 3. Volume buses (global volume + mute)
190
+
191
+ Every sound's final loudness is `master × bus × volume`, where `bus` is the
192
+ node's `sfx` or `music` gain. The buses live on the engine as `engine.audio`:
193
+
194
+ ```ts
195
+ engine.audio.master = 0.8; // dim everything (0..1)
196
+ engine.audio.sfx = 1; // SFX bus gain
197
+ engine.audio.music = 0.4; // quieter background music
198
+ engine.audio.muted = true; // global mute toggle (true → all sound 0)
199
+ ```
200
+
201
+ Values clamp to `[0,1]`; non-finite sets are ignored. `engine.audio.changed`
202
+ fires on any change (drives a settings UI). Procedural SFX go to whichever `bus`
203
+ the node declares (default `sfx`); music-ish loops typically set `bus: "music"`.
204
+ Changes apply to currently-playing `src` clips on the next frame and to every new
205
+ sound immediately.
206
+
207
+ A settings slider just writes these numbers — no per-node bookkeeping.
208
+
209
+ ---
210
+
211
+ ## 4. Built-in audio catalog (real sound files)
212
+
213
+ Alongside the procedural set, the package ships a small set of **real**,
214
+ owner-licensed (MIT-ish) sound files — handy when you want recorded SFX. They're
215
+ in the same catalog as the art (`kind: "audio"`):
216
+
217
+ ```bash
218
+ bunx incanto-assets list --json # filter kind === "audio"
219
+ ```
220
+
221
+ Built-ins include `explosion`, `attacked` (hurt), `gold-loot` (coin), `slash`,
222
+ `hit-metal-bang`, `heal`, `spells-cast`, `ice-spear`, `monster-died`, `smite`,
223
+ `walk` (footstep), `ui-click`. Each entry's **`url`** is the drop-in contract:
224
+ `incanto/assets/audio/<file>` — exactly like the packaged sprites. Put it in
225
+ `AudioPlayer.src`:
226
+
227
+ ```jsonc
228
+ { "type": "AudioPlayer", "name": "Boom",
229
+ "props": { "src": "incanto/assets/audio/explosion.mp3", "bus": "sfx" } }
230
+ ```
231
+
232
+ In a bundler game (vite, the usual target) you can `import url from
233
+ 'incanto/assets/audio/explosion.mp3'` and write that into `src`; or
234
+ `bunx incanto-assets copy <name> --out public/assets` to copy the file. For more
235
+ or different sound, any audio URL works (asset MCP servers, your own CDN) — never
236
+ invent URLs; use the catalog or a real source. Large music tracks are NOT
237
+ bundled (keep the package small) — reference them by URL.
238
+
239
+ ### Editor
240
+
241
+ The scene composer (`bunx incanto-editor`) shows `preset`, `bus`, and `rolloff`
242
+ as dropdowns, `spatial` as a checkbox, `refDistance`/`maxDistance` as numbers,
243
+ and `AudioPlayer.src` as an **audio asset picker** (browse the built-in sounds
244
+ by name, or paste a URL). Zero-asset SFX = pick a `preset` instead of a `src`.
245
+
246
+ ---
247
+
248
+ ## 5. Music manager (crossfade / loop, single background track)
249
+
250
+ For **background music** you usually want ONE track at a time and the ability to
251
+ **crossfade** to another (exploration → boss, level A → level B). That's
252
+ `engine.music` — a single-track manager that streams a clip and ramps gains:
253
+
254
+ ```ts
255
+ // Loop a track (full volume, music bus):
256
+ engine.music.play('incanto/assets/audio/theme.mp3');
257
+
258
+ // Options: { loop = true, fadeIn = 0 (seconds), bus = 'music' }
259
+ engine.music.play('intro.mp3', { loop: false, fadeIn: 2 });
260
+
261
+ // Equal-power crossfade to a new track over N seconds (old fades out as new
262
+ // fades in — no mid-fade volume dip):
263
+ engine.music.crossfadeTo('boss.mp3', 3);
264
+
265
+ // Fade the current track out and stop (0 = instant):
266
+ engine.music.stop(2);
267
+
268
+ // Per-manager volume (on top of the bus gain):
269
+ engine.music.setVolume(0.5);
270
+
271
+ // What's playing (the logically-current track src, or null):
272
+ engine.music.current;
273
+ ```
274
+
275
+ It routes through the **music bus** by default, so `engine.audio.music` /
276
+ `master` / `muted` dim it for free (a settings slider needs no extra wiring).
277
+ Fades advance automatically each frame (the engine ticks the manager). Like all
278
+ audio it needs a user gesture; `createGame2D`/`createGame3D` resume + (re)start a
279
+ gesture-blocked track on the first pointer/key, same as SFX.
280
+
281
+ **Why a manager and not an `AudioPlayer` node?** A node is a one-shot/loop with
282
+ no concept of "the current music"; crossfading needs to hold two tracks and ramp
283
+ both. The imperative manager (call it from a behavior on scene change) is the
284
+ clean fit; the looping-`src` `AudioPlayer` (§2) stays the right tool for a *fixed*
285
+ background loop authored in scene JSON. Wire `crossfadeTo` from gameplay:
286
+
287
+ ```ts
288
+ // e.g. in a behavior when the boss spawns:
289
+ this.tree.engine.music.crossfadeTo('incanto/assets/audio/boss.mp3', 3);
290
+ ```
291
+
292
+ > Large music files are NOT bundled — reference them by URL (see §4). Headless:
293
+ > the manager's state machine still runs (`current` updates) but plays nothing.
294
+
295
+ ---
296
+
297
+ ## Decision guide
298
+
299
+ - **Need a quick game sound (coin/jump/hit/explosion/…)** → set `preset`. Done.
300
+ - **Want a specific recorded sound** → built-in audio catalog `src`, or any URL.
301
+ - **A fixed background loop authored in JSON** → `src` + `loop:true` +
302
+ `autoplay:true` + `bus:"music"` on an `AudioPlayer`.
303
+ - **Switchable / crossfading music** → `engine.music.play(...)` /
304
+ `crossfadeTo(...)` / `stop(...)` (§5).
305
+ - **3D sound that pans + fades with distance** → `spatial:true` on an
306
+ `AudioPlayer` in a 3D scene (§2b); listener = the active `Camera3D`.
307
+ - **Global volume / mute / settings** → `engine.audio.master/sfx/music/muted`.
308
+ - **Verifying headlessly** → audio is a no-op in the VM; assert `play()` doesn't
309
+ throw and check your gameplay/score logic instead (see incanto-verifying).
@@ -0,0 +1,169 @@
1
+ ---
2
+ name: incanto-behaviors-and-scripts
3
+ description: Attach vibe-coded TypeScript to JSON nodes via the Behavior system — registerBehavior, script props, JSON connections to behavior methods, Timer, CharacterController2D. Use when adding game logic.
4
+ ---
5
+
6
+ # Behaviors & Scripts
7
+
8
+ > Shipped inside the `incanto` npm package — this document always matches the
9
+ > installed engine version. Sibling skills live in `node_modules/incanto/skills/`.
10
+
11
+ > **For ready-made behaviors, see `incanto-gameplay-behaviors.md` first.** Health,
12
+ > score, pickups, contact damage, lifetimes and interaction ship built-in and
13
+ > auto-register in `createGame` — wire them from JSON with no code. Write a custom
14
+ > behavior (this doc) only for game-specific logic the library doesn't cover.
15
+
16
+ JSON owns STRUCTURE; TypeScript owns BEHAVIOR. A node carries at most ONE script:
17
+
18
+ ```json
19
+ { "name": "Level", "type": "Node2D",
20
+ "script": { "name": "CoinCounter", "props": { "target": 10 } } }
21
+ ```
22
+
23
+ ```ts
24
+ import { Behavior, registerBehavior } from 'incanto';
25
+
26
+ class CoinCounter extends Behavior {
27
+ static readonly props = { target: { default: 0 } }; // same schema model as nodes
28
+ target = 0;
29
+ collected = 0;
30
+
31
+ override onReady(): void {} // once per instance, after the node's own
32
+ override update(dt: number): void {} // every frame
33
+ override fixedUpdate(dt: number): void {} // every fixed step
34
+ onCoinCollected(other: unknown): void { // JSON connections call this
35
+ this.collected += 1;
36
+ }
37
+ }
38
+ registerBehavior('CoinCounter', CoinCounter); // EXPLICIT — before loadScene
39
+ ```
40
+
41
+ ## The three-artifact contract (keep them in sync!)
42
+
43
+ 1. scene JSON `"script": {"name": "CoinCounter"}`
44
+ 2. the TS class
45
+ 3. `registerBehavior('CoinCounter', CoinCounter)` in the game entry
46
+
47
+ A missing registration is a hard `UNKNOWN_BEHAVIOR` at load listing registered names.
48
+ Script `props` validate against `static props` (`UNKNOWN_PROP` / `PROP_TYPE_MISMATCH`).
49
+ Hot reload: `registerBehavior(name, ctor, { replace: true })` swaps the implementation
50
+ under an existing name (re-registering a different class without it → `DUPLICATE_BEHAVIOR`).
51
+
52
+ ## What a Behavior can reach
53
+
54
+ - `this.node` — the host node (cast to its type when needed)
55
+ - `this.getNode(path)` / `this.emit(...)` / `this.on(...)` — node delegates
56
+ - `this.engine` / `this.input` — via the scene tree (TREE_VIOLATION if the scene isn't
57
+ set on an Engine)
58
+ - Engine access in `onReady` (`this.engine` / `this.rng` / `this.log`) works under
59
+ `createGame2D/3D` — they pass `{ engine }` to `loadScene`, attaching it BEFORE
60
+ `onReady` fires. Under a manual boot it throws `TREE_VIOLATION` until
61
+ `engine.setScene(scene)`: prefer `createGame` (or `loadScene(json, { engine })`),
62
+ or defer engine-dependent work to the first `update`. Two onReady caveats even
63
+ with the engine attached: `engine.scene` is still null (setScene runs after),
64
+ and the scene's `input` actions are not declared yet — query input from
65
+ `update`/`fixedUpdate`, never `onReady`.
66
+ - `this.rng` — seeded engine randomness: `next()` [0,1), `range(min,max)`,
67
+ `int(min,max)` (inclusive), `pick(arr)`. **Never `Math.random()` in game logic** —
68
+ with `new Engine({ seed: 42 })` a run replays identically (scripted verification).
69
+ - `this.log` — the engine log channel (`debug/info/warn/error(...parts)`; debug overlay
70
+ and headless harnesses tail it via `entries()` / the live `added` signal)
71
+
72
+ ## Runtime API (the imperative half)
73
+
74
+ What behavior code actually calls at runtime — all instance methods, no globals:
75
+
76
+ | Call | Semantics |
77
+ |---|---|
78
+ | `parent.addChild(node)` | attach a DETACHED node (already-parented → `TREE_VIOLATION`); sibling name collisions auto-rename (`Enemy` → `Enemy2`) |
79
+ | `duplicateNode(node)` | deep-clone via serialize→rebuild — returns a **detached** node; attach it explicitly with `addChild` |
80
+ | `node.queueFree()` | deferred destruction — flushed at the END of the current update pass (queued during a flush = freed next pass) |
81
+ | `node.free()` | immediate detach + teardown (children first, signals disconnected) |
82
+ | `root.getNodesByName('Enemy')` | EVERY node with that name in the subtree, document order |
83
+ | `node.getNodeOrNull(path)` | like `getNode` but `null` instead of `NODE_NOT_FOUND` |
84
+ | `node.getNode('%Player')` | unique-name lookup across the whole tree (≥2 matches → `DUPLICATE_UNIQUE_NAME`) |
85
+ | `this.node.tree?.getNodesInGroup('enemies')` | group query (also `tree.callGroup(group, method, ...args)`) |
86
+ | `engine.stop()` / `engine.start()` | pause / resume the loop — stop resets the clock and accumulator, so no banked sim time leaks into the resume |
87
+ | `engine.step()` | advance exactly ONE fixed step + one update (both dt = the fixed step) — the unit of time for headless tests |
88
+ | `engine.tick(timestampMs)` | manual frame advance — takes an **absolute** ms timestamp (rAF-style), NOT a dt; the first call after (re)start only primes the clock |
89
+ | `engine.setScene(scene)` | swap scenes: the previous root is freed, the input map is cleared and redeclared from the new scene's `input{}`, then `sceneChanged` fires |
90
+ | `engine.stats()` | live perf counters `{ fps, frameMs, nodes, running }` — fps/frameMs average the last ~60 REAL `tick` frames (headless `step()` runs report 0), nodes is the current tree size. GPU counters (triangles/draw calls) live on `renderer.stats()` / the merged `game.stats()` |
91
+
92
+ The recurring traps: `duplicateNode` does NOT insert the clone anywhere — a
93
+ "spawner that does nothing" usually forgot `addChild`. `engine.tick(16)` does
94
+ not mean "advance 16ms": tick wants wall-clock timestamps, so scripted loops
95
+ should call `engine.step()` instead. And `queueFree` inside `update` is always
96
+ safe — the node keeps existing until the pass ends.
97
+
98
+ ## Custom signals (declare before emit)
99
+
100
+ Signals are part of a node's contract: emitting or subscribing an UNDECLARED signal is a
101
+ hard `UNKNOWN_SIGNAL` listing the declared ones. A behavior declares its custom signals
102
+ with `static signals` (merged up the class chain) — auto-declared onto its node at load:
103
+
104
+ ```ts
105
+ class GameRules extends Behavior {
106
+ static signals = ['gameOver']; // REQUIRED before this.emit('gameOver')
107
+ onPlayerDied(): void { this.emit('gameOver'); }
108
+ }
109
+ ```
110
+
111
+ Built-in node signals (`timeout`, `triggerEnter`, `animationFinished`, …) are already
112
+ declared by their classes. Escape hatch for runtime one-offs: `node.declareSignal(name)`
113
+ (instance-only — ad-hoc declarations do NOT survive `duplicateNode`/serialize; prefer
114
+ `static signals`); `node.declaredSignalNames()` lists everything a node may emit.
115
+
116
+ ## Connections → behavior methods
117
+
118
+ ```json
119
+ "connections": [
120
+ { "signal": "triggerEnter", "from": "Coins/CoinA", "to": ".", "handler": "onCoinCollected",
121
+ "once": true, "filter": { "group": "player" } }
122
+ ]
123
+ ```
124
+ - `handler` must exist on the target NODE or its BEHAVIOR — hard `UNKNOWN_HANDLER`
125
+ otherwise (validated at load; node methods take precedence on invocation).
126
+ - Handlers receive the EMITTED args only (e.g. the other body for `triggerEnter`) — not
127
+ the emitting node. For per-emitter logic, attach a small behavior to the emitter itself
128
+ (see the `Pickup` pattern: a coin's own `triggerEnter` → its own `onTaken` → `queueFree`).
129
+
130
+ ## Timer (core node — never setTimeout in game logic)
131
+
132
+ ```json
133
+ { "name": "Spawner", "type": "Timer", "props": { "waitTime": 2, "autostart": true } }
134
+ ```
135
+ Emits `timeout` every `waitTime` s (`oneShot` for once). API: `start(time?)`, `stop()`, `running`.
136
+
137
+ ## CharacterController2D (JSON-only playable characters)
138
+
139
+ Child of a `CharacterBody2D` (hard error otherwise):
140
+ ```json
141
+ { "name": "Player", "type": "CharacterBody2D",
142
+ "props": { "collider": { "shape": "capsule", "radius": 10, "height": 20 } },
143
+ "children": [
144
+ { "name": "Controller", "type": "CharacterController2D",
145
+ "props": { "mode": "platformer", "maxSpeed": 260, "jumpHeight": 146 } }
146
+ ] }
147
+ ```
148
+ - `platformer`: x movement + gravity (scene `physics.gravity[1]`) + jump (`jumpHeight` px)
149
+ - `topDown`: full-axis movement, no gravity
150
+ - Defaults: `mode 'platformer'`, `maxSpeed 220`, `jumpHeight 64`, `moveAction 'move'`,
151
+ `jumpAction 'jump'`. Timer defaults: `waitTime 1`, `oneShot false`, `autostart false`.
152
+ - ⚠️ Connection handler names must not collide with Node API methods (`emit`, `update`,
153
+ `on`, `queueFree`, …) — the NODE method wins silently. Prefix handlers with `on`.
154
+
155
+ Reference: [examples/2d-phaser-sprite-character-gravity](https://github.com/rareboe/Incanto/tree/main/examples/2d-phaser-sprite-character-gravity) — engine nodes + one small PlayerControl behavior
156
+ (`CoinCounter`, `Pickup`) wired entirely through JSON connections. Verified in Chromium.
157
+
158
+ ## Spawning nodes at runtime
159
+
160
+ Nodes you create in code and intend to serialize need uids like everything
161
+ else — mint them with the engine's generator, never by hand:
162
+
163
+ ```ts
164
+ import { newUid } from 'incanto';
165
+ import { Sprite2D } from 'incanto/2d'; // node classes live in their dimension entry
166
+
167
+ const coin = new Sprite2D('Coin');
168
+ coin.uid = newUid(); // n_xxxxxxxxxxxxxxxx (crypto, 16 base36 chars)
169
+ ```