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.
- package/LICENSE +30 -0
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES.md +88 -0
- package/assets/audio/attacked.mp3 +0 -0
- package/assets/audio/explosion.mp3 +0 -0
- package/assets/audio/gold_loot.mp3 +0 -0
- package/assets/audio/heal.mp3 +0 -0
- package/assets/audio/hit_metal_bang.mp3 +0 -0
- package/assets/audio/ice_spear.mp3 +0 -0
- package/assets/audio/monster_died.mp3 +0 -0
- package/assets/audio/slash.mp3 +0 -0
- package/assets/audio/smite.mp3 +0 -0
- package/assets/audio/spells_cast.mp3 +0 -0
- package/assets/audio/ui_click.wav +0 -0
- package/assets/audio/walk.mp3 +0 -0
- package/assets/catalog.json +390 -0
- package/assets/characters/2dbasic.json +41 -0
- package/assets/characters/2dbasic.png +0 -0
- package/assets/characters/ghost.json +46 -0
- package/assets/characters/ghost.png +0 -0
- package/assets/characters/goblin.json +40 -0
- package/assets/characters/goblin.png +0 -0
- package/assets/characters/medieval-knight.json +41 -0
- package/assets/characters/medieval-knight.png +0 -0
- package/assets/effects/swoosh.png +0 -0
- package/assets/items/box.png +0 -0
- package/assets/items/buff_potion.png +0 -0
- package/assets/items/coin.png +0 -0
- package/assets/items/gem.png +0 -0
- package/assets/items/gold.png +0 -0
- package/assets/items/hp_potion.png +0 -0
- package/assets/items/locked_item_box.png +0 -0
- package/assets/items/map.png +0 -0
- package/assets/items/resurrection_potion.png +0 -0
- package/assets/items/super_box.png +0 -0
- package/assets/items/trap.png +0 -0
- package/assets/tiles/floor00.jpg +0 -0
- package/assets/tiles/minecraft-tiles.png +0 -0
- package/assets/tiles/wall00.jpg +0 -0
- package/assets/vegetation/ash_color.png +0 -0
- package/assets/vegetation/aspen_color.png +0 -0
- package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
- package/assets/vegetation/ground/dirt_color.jpg +0 -0
- package/assets/vegetation/ground/dirt_normal.jpg +0 -0
- package/assets/vegetation/ground/grass.jpg +0 -0
- package/assets/vegetation/oak_color.png +0 -0
- package/assets/vegetation/pine_color.png +0 -0
- package/bin/incanto-assets.mjs +107 -0
- package/bin/incanto-check.mjs +107 -0
- package/bin/incanto-editor.mjs +343 -0
- package/bin/incanto-env.mjs +144 -0
- package/bin/incanto-model.mjs +296 -0
- package/bin/incanto-play.mjs +219 -0
- package/bin/incanto-skills.mjs +71 -0
- package/dist/2d.d.ts +642 -0
- package/dist/2d.js +44 -0
- package/dist/3d.d.ts +1860 -0
- package/dist/3d.js +5 -0
- package/dist/agent8-DzU2fFyH.js +129 -0
- package/dist/audio-player-DqUR3XFs.d.ts +110 -0
- package/dist/behavior-BAQq7HGM.d.ts +851 -0
- package/dist/create-game-BdjpTHrW.js +1725 -0
- package/dist/create-game-CZHROKcT.js +527 -0
- package/dist/debug-draw-CZmOYjL2.js +13 -0
- package/dist/debug.d.ts +66 -0
- package/dist/debug.js +658 -0
- package/dist/duplicate-DP2WPYom.js +22 -0
- package/dist/env.d.ts +430 -0
- package/dist/env.js +3152 -0
- package/dist/errors-BMFaY68Q.d.ts +33 -0
- package/dist/errors-BpWbnbb_.js +13 -0
- package/dist/gameplay-Ccruc3Wd.js +1501 -0
- package/dist/gameplay.d.ts +543 -0
- package/dist/gameplay.js +2 -0
- package/dist/heightmap-CroQPEER.js +185 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +62 -0
- package/dist/json-BLk7H2Qa.js +30 -0
- package/dist/loader-CGs_G-r0.js +919 -0
- package/dist/loader-Mo0KghCv.d.ts +41 -0
- package/dist/net.d.ts +427 -0
- package/dist/net.js +772 -0
- package/dist/noise-CGUMx44x.js +82 -0
- package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
- package/dist/particle-sim-DYuSUxvK.js +1319 -0
- package/dist/physics-2d-KuMWPTf6.js +288 -0
- package/dist/physics-3d-Dl67vOLT.js +434 -0
- package/dist/react.d.ts +65 -0
- package/dist/react.js +209 -0
- package/dist/register-BuUV1_KB.js +561 -0
- package/dist/register-CNlYAS1_.js +10634 -0
- package/dist/register-DPEV9_9t.js +851 -0
- package/dist/register-Dasmnurl.js +374 -0
- package/dist/registry-BVJ2HbCn.js +132 -0
- package/dist/rng-DP-SR7eg.js +38 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-CcoWb32N.d.ts +104 -0
- package/dist/test.d.ts +158 -0
- package/dist/test.js +275 -0
- package/dist/touch-031PxtCR.js +208 -0
- package/dist/vite.d.ts +26 -0
- package/dist/vite.js +57 -0
- package/editor/assets/GameServer-C56iOUgF.js +1 -0
- package/editor/assets/agent8-Bp7QFI7v.js +1 -0
- package/editor/assets/index-DF3tMeKJ.css +1 -0
- package/editor/assets/index-Dl2pjA8e.js +7365 -0
- package/editor/assets/rapier-CEuLKeCu.js +1 -0
- package/editor/assets/rapier-DE6a0vmv.js +1 -0
- package/editor/index.html +169 -0
- package/package.json +97 -0
- package/schemas/scene.schema.json +4254 -0
- package/skills/README.md +9 -0
- package/skills/incanto-3d-character.md +229 -0
- package/skills/incanto-3d-models.md +151 -0
- package/skills/incanto-assets.md +118 -0
- package/skills/incanto-audio.md +309 -0
- package/skills/incanto-behaviors-and-scripts.md +169 -0
- package/skills/incanto-building-2d-games.md +242 -0
- package/skills/incanto-building-3d-games.md +245 -0
- package/skills/incanto-editor.md +163 -0
- package/skills/incanto-environment.md +743 -0
- package/skills/incanto-gameplay-behaviors.md +707 -0
- package/skills/incanto-multiplayer.md +264 -0
- package/skills/incanto-node-reference.md +797 -0
- package/skills/incanto-physics-and-input.md +164 -0
- package/skills/incanto-scene-json-authoring.md +325 -0
- package/skills/incanto-verifying-your-game.md +191 -0
- package/skills/incanto-web-integration.md +96 -0
- package/templates/agent8-server.js +84 -0
- 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
|
+
```
|