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,707 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: incanto-gameplay-behaviors
|
|
3
|
+
description: The batteries-included game-logic library (incanto/gameplay) — ready-made Behaviors you wire from scene JSON with ZERO custom code. State & interaction: Health (hit points, i-frames, regen), Lifetime (self-destruct), ScoreKeeper (score/lives/win-lose hub), Pickup (collectibles), Collector (collect-N tally), DamageOnContact (projectiles/hazards), Interactable (press-to-use doors/levers/NPCs). Movement, AI, camera, spawning & juice: FollowCamera (camera tracks a target), Patrol (waypoint paths), Chase (homing enemy AI), Wander (seeded roaming), ZombieAI (wander-then-charge monster AI with proximity aggro), MoveTo (eased position tween), Oscillate (sine float/spin/pulse), Spawner (interval spawning), WaveSpawner (sequenced enemy waves), Projectile (straight-line motion). Use when a game needs health, score, pickups, damage, timers, interaction, enemy movement/AI, camera-follow, tweens, or spawning — BEFORE hand-rolling them as custom behaviors.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Gameplay behaviors — the built-in logic library
|
|
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
|
+
Incanto ships the game logic every game re-invents, as ready-made `Behavior`s.
|
|
12
|
+
You compose them through scene JSON — `"script"` blobs attach them, `connections`
|
|
13
|
+
wire one behavior's signal to another's method. **No TypeScript required.**
|
|
14
|
+
|
|
15
|
+
`createGame2D`/`createGame3D` (and the headless `runScript`/`createPlaySession`)
|
|
16
|
+
**auto-register all of them** — just reference the names in scene JSON. Opt out
|
|
17
|
+
with `gameplay: false`, or register a subset yourself:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { registerGameplayBehaviors } from 'incanto/gameplay';
|
|
21
|
+
registerGameplayBehaviors(); // all of them (default in createGame)
|
|
22
|
+
|
|
23
|
+
import { Health, Pickup, registerBehavior } from 'incanto/gameplay';
|
|
24
|
+
registerBehavior('Health', Health); // or pick just what you use
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A same-named user behavior always wins: `createGame({ behaviors: { Health: MyHealth } })`
|
|
28
|
+
registers AFTER the built-ins (with `replace: true`), overriding `Health`.
|
|
29
|
+
|
|
30
|
+
All behaviors are **dimension-agnostic** (operate on `position` arrays / Area
|
|
31
|
+
trigger signals, never three) and **pure logic** — they verify headlessly.
|
|
32
|
+
|
|
33
|
+
## How they compose (signals → methods)
|
|
34
|
+
|
|
35
|
+
A behavior declares **signals** it emits and exposes **methods** other behaviors
|
|
36
|
+
or `connections` call. Wiring is just JSON:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{ "connections": [
|
|
40
|
+
{ "signal": "collected", "from": "Coin", "to": "/root", "handler": "addScore" },
|
|
41
|
+
{ "signal": "died", "from": "Player", "to": "/root", "handler": "loseLife" }
|
|
42
|
+
] }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The signal's args become the handler's args (e.g. `collected(value, other)` →
|
|
46
|
+
`addScore(value)`).
|
|
47
|
+
|
|
48
|
+
### Node paths — connections vs. behavior props (READ THIS)
|
|
49
|
+
|
|
50
|
+
Two kinds of node path resolve from DIFFERENT origins — mixing them up is the
|
|
51
|
+
#1 wiring bug:
|
|
52
|
+
|
|
53
|
+
- **Connection `from`/`to` resolve from the SCENE ROOT.** Use a name relative to
|
|
54
|
+
the root (`Player`, `Player/Weapon`), `/root` / `.` for the root node itself,
|
|
55
|
+
or `/root/<name>` absolute. `/root` is the tree root **whatever it is named**.
|
|
56
|
+
- **A behavior's node-path prop** (`Chase.target`, `FollowCamera.target`,
|
|
57
|
+
Spawner `prefab`) **resolves from the node the behavior sits on.** A spawned
|
|
58
|
+
enemy (a child of the Spawner) reaches the player with the ABSOLUTE
|
|
59
|
+
`/root/Player`, never a bare `Player` (which would look under the enemy).
|
|
60
|
+
|
|
61
|
+
**Never write `/root/<RootName>/...`.** If your root node is named `Game`, the
|
|
62
|
+
path is `/root/Player` — NOT `/root/Game/Player`: `/root/` already *is* the
|
|
63
|
+
`Game` root, so `/root/Game/...` double-counts it and dangles at load. Likewise
|
|
64
|
+
a connection `to` the root is `/root` (or `.`), never `/root/Game`.
|
|
65
|
+
|
|
66
|
+
> Behaviors that react to collisions (`Pickup`, `DamageOnContact`) listen to the
|
|
67
|
+
> unified `triggerEnter(other)` signal, so their node **must be an `Area2D`/`Area3D`**
|
|
68
|
+
> (or another body that emits it). Attach them to a non-Area node and they fail
|
|
69
|
+
> loudly at load (`UNKNOWN_SIGNAL`) — see `incanto-physics-and-input.md` for Areas.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Health
|
|
74
|
+
|
|
75
|
+
Hit points with regen and post-hit invulnerability (i-frames). The universal
|
|
76
|
+
"can be hurt / can die" behavior. Hurt it via `damage(n)` (e.g. from
|
|
77
|
+
`DamageOnContact`); heal/kill via `heal(n)` / `kill()`. Clamps to `0..max`,
|
|
78
|
+
dies once.
|
|
79
|
+
|
|
80
|
+
| Prop | Default | Meaning |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `max` | `100` | maximum HP; `current` starts here |
|
|
83
|
+
| `regenPerSec` | `0` | HP regained per second (0 = off) |
|
|
84
|
+
| `invulnerableFor` | `0` | seconds of immunity after a hit (i-frames; 0 = off) |
|
|
85
|
+
| `freeOnDeath` | `false` | `queueFree()` this node when it dies (clone-safe) |
|
|
86
|
+
|
|
87
|
+
Signals: `damaged(amount, current)` · `healed(amount, current)` · `died`
|
|
88
|
+
Methods: `damage(n)` · `heal(n)` · `kill()` — state: `current`, `isDead`
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "name": "Player", "type": "CharacterBody2D", "groups": ["player"],
|
|
92
|
+
"props": { "collider": { "shape": "capsule", "radius": 12, "height": 32 } },
|
|
93
|
+
"script": { "name": "Health", "props": { "max": 50, "invulnerableFor": 1 } } }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
> **Freeing dying SPAWNED entities — use `freeOnDeath`, not a connection.**
|
|
97
|
+
> Scene `connections` are NOT copied onto Spawner/WaveSpawner clones, so a
|
|
98
|
+
> `{ signal: "died", … handler: "queueFree" }` connection only ever wires the
|
|
99
|
+
> TEMPLATE, never the live clones — enemies would never disappear on death. Put
|
|
100
|
+
> `Health` (with `freeOnDeath: true`) on the **entity ROOT** instead: it is
|
|
101
|
+
> node-local, so it clones perfectly and frees the whole enemy with no wiring.
|
|
102
|
+
> (Connections are still the right tool for NON-cloned nodes, e.g. the player's
|
|
103
|
+
> `died → ScoreKeeper.loseLife`.)
|
|
104
|
+
|
|
105
|
+
## Lifetime
|
|
106
|
+
|
|
107
|
+
Self-destruct after a fixed time — bullets, particles, temporary spawns. Emits
|
|
108
|
+
`expired` then `queueFree()`s its node. `startOnSignal: true` defers the
|
|
109
|
+
countdown until `startTimer()` (wire a signal → `startTimer`).
|
|
110
|
+
|
|
111
|
+
| Prop | Default | Meaning |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `seconds` | `1` | lifespan before self-free |
|
|
114
|
+
| `startOnSignal` | `false` | defer the countdown until `startTimer()` |
|
|
115
|
+
|
|
116
|
+
Signals: `expired` — Methods: `startTimer()`
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{ "name": "Bullet", "type": "Area2D",
|
|
120
|
+
"props": { "collider": { "shape": "circle", "radius": 3 } },
|
|
121
|
+
"script": { "name": "Lifetime", "props": { "seconds": 2 } } }
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## ScoreKeeper
|
|
125
|
+
|
|
126
|
+
The game's **state hub** — score, lives, win/lose. Put it on the Root/Game node
|
|
127
|
+
and feed gameplay signals into its methods. Reaching `scoreToWin` emits `won`
|
|
128
|
+
once; reaching 0 lives emits `lost` once.
|
|
129
|
+
|
|
130
|
+
| Prop | Default | Meaning |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `score` | `0` | starting score |
|
|
133
|
+
| `lives` | `0` | starting lives (0 = lives disabled) |
|
|
134
|
+
| `scoreToWin` | `0` | score that triggers `won` (0 = disabled) |
|
|
135
|
+
|
|
136
|
+
Signals: `scoreChanged(score)` · `won` · `lost` · `lifeLost(lives)`
|
|
137
|
+
Methods: `addScore(n)` · `setScore(n)` · `loseLife()`
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{ "name": "Game", "type": "Node",
|
|
141
|
+
"script": { "name": "ScoreKeeper", "props": { "scoreToWin": 10, "lives": 3 } } }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Pickup
|
|
145
|
+
|
|
146
|
+
A collectible that vanishes when a collector overlaps it. **Goes on an Area.**
|
|
147
|
+
On `triggerEnter` with a node in `collectorGroup`, emits `collected(value, other)`
|
|
148
|
+
then `queueFree()`s itself. Wire `collected → ScoreKeeper.addScore` (value is
|
|
149
|
+
arg 0) or `→ Collector.collect`.
|
|
150
|
+
|
|
151
|
+
| Prop | Default | Meaning |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `value` | `1` | worth (passed as `collected`'s first arg) |
|
|
154
|
+
| `kind` | `"coin"` | free-form label ('coin', 'gem', 'key', …) |
|
|
155
|
+
| `collectorGroup` | `"player"` | only nodes in this group collect it |
|
|
156
|
+
|
|
157
|
+
Signals: `collected(value, other)`
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{ "name": "Coin", "type": "Area2D",
|
|
161
|
+
"props": { "collider": { "shape": "circle", "radius": 8 } },
|
|
162
|
+
"script": { "name": "Pickup", "props": { "value": 5 } } }
|
|
163
|
+
```
|
|
164
|
+
```json
|
|
165
|
+
{ "connections": [
|
|
166
|
+
{ "signal": "collected", "from": "Coin", "to": "/root", "handler": "addScore" }
|
|
167
|
+
] }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Collector
|
|
171
|
+
|
|
172
|
+
A per-actor "collect N" tally, on the collector itself (usually the player). Adds
|
|
173
|
+
its node to `group` at ready (so matching Pickups count it as a collector). Wire
|
|
174
|
+
a Pickup's `collected → Collector.collect`.
|
|
175
|
+
|
|
176
|
+
| Prop | Default | Meaning |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `group` | `"player"` | group it joins (Pickups match `collectorGroup` against this) |
|
|
179
|
+
|
|
180
|
+
Signals: `totalChanged(total)` — Methods: `collect(value)` — state: `total`
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{ "name": "Player", "type": "CharacterBody2D",
|
|
184
|
+
"script": { "name": "Collector", "props": { "group": "player" } } }
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## DamageOnContact
|
|
188
|
+
|
|
189
|
+
Deals damage to whatever it touches — projectiles, spikes, lava, enemy hitboxes.
|
|
190
|
+
**Goes on an Area.** On `triggerEnter`, finds the contacted entity's `Health` and
|
|
191
|
+
calls `damage(amount)`, then emits `dealtDamage(amount, healthOwnerNode)`.
|
|
192
|
+
`oncePerTarget` (default) prevents re-hitting a resting body every frame;
|
|
193
|
+
`destroySelf` frees the hazard after a hit (single-use projectiles).
|
|
194
|
+
|
|
195
|
+
| Prop | Default | Meaning |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| `amount` | `10` | HP removed per contact |
|
|
198
|
+
| `targetGroup` | `""` | only damage targets whose Health-owner is in this group (`""` = any) |
|
|
199
|
+
| `oncePerTarget` | `true` | damage each target at most once |
|
|
200
|
+
| `destroySelf` | `false` | `queueFree()` after the first hit |
|
|
201
|
+
|
|
202
|
+
Signals: `dealtDamage(amount, target)`
|
|
203
|
+
|
|
204
|
+
**`targetGroup` — stop enemies killing each other.** With no collision layers in
|
|
205
|
+
v0 (everything overlaps everything), an enemy's contact hitbox would damage any
|
|
206
|
+
`Health` it touches, including other enemies clustered at the spawn point. Gate
|
|
207
|
+
it by group: a player weapon uses `"targetGroup": "enemy"`, each enemy hitbox
|
|
208
|
+
uses `"targetGroup": "player"` — so only the intended side is hurt. The group is
|
|
209
|
+
checked on the node that OWNS the `Health`, not the collider that delivered the
|
|
210
|
+
contact.
|
|
211
|
+
|
|
212
|
+
**Robust `Health` resolution.** A real entity rarely carries `Health` on the
|
|
213
|
+
exact collider that overlaps. `DamageOnContact` searches, nearest-first: the
|
|
214
|
+
contacted node and its descendants, then up the ANCESTOR CHAIN (so `Health`
|
|
215
|
+
on the entity ROOT is found from a child collider). It never searches an
|
|
216
|
+
ancestor's sibling subtrees, so a hazard can't reach an unrelated entity
|
|
217
|
+
across the scene — put `Health` on the entity root (recommended) or on the
|
|
218
|
+
collider's own subtree.
|
|
219
|
+
The first `Health` wins; its node is the `dealtDamage` target and the
|
|
220
|
+
`targetGroup` subject. So you can put `Health` on the enemy ROOT and a collider
|
|
221
|
+
on a child `Hit` Area — a weapon contacting either finds the root's `Health`.
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{ "name": "Spike", "type": "Area2D",
|
|
225
|
+
"props": { "collider": { "shape": "rect", "size": [32, 8] } },
|
|
226
|
+
"script": { "name": "DamageOnContact", "props": { "amount": 20 } } }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Scoring on kill (clone-safe).** Wire the KILLER's `dealtDamage → addScore`.
|
|
230
|
+
The weapon is a non-cloned player child, so its connection survives — unlike a
|
|
231
|
+
connection on a spawned enemy, which never clones:
|
|
232
|
+
`{ "signal": "dealtDamage", "from": "Player/Weapon", "to": "/root", "handler": "addScore" }`.
|
|
233
|
+
|
|
234
|
+
## Interactable
|
|
235
|
+
|
|
236
|
+
A proximity-gated "press to use" — doors, levers, chests, NPCs. Each frame, if an
|
|
237
|
+
actor in `actorGroup` is within `range` (distance on `position` arrays, 2D or 3D)
|
|
238
|
+
and the `action` input was just pressed, emits `interacted(actor)` (nearest
|
|
239
|
+
in-range actor). Declare the input action in the scene `input{}`.
|
|
240
|
+
|
|
241
|
+
| Prop | Default | Meaning |
|
|
242
|
+
|---|---|---|
|
|
243
|
+
| `action` | `"interact"` | input action (button) that triggers it |
|
|
244
|
+
| `range` | `2` | max distance an actor may be |
|
|
245
|
+
| `actorGroup` | `"player"` | only nodes in this group can interact |
|
|
246
|
+
|
|
247
|
+
Signals: `interacted(actor)`
|
|
248
|
+
|
|
249
|
+
```json
|
|
250
|
+
{ "input": { "interact": { "type": "button", "keys": ["KeyE"] } },
|
|
251
|
+
"root": { "name": "Level", "type": "Node2D", "children": [
|
|
252
|
+
{ "name": "Door", "type": "Node2D", "props": { "position": [200, 100] },
|
|
253
|
+
"script": { "name": "Interactable", "props": { "range": 48 } } }
|
|
254
|
+
] } }
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
# Movement, AI, camera & spawning
|
|
260
|
+
|
|
261
|
+
These act on the node's transform (`position` array — 2D `[x,y]`, 3D `[x,y,z]`;
|
|
262
|
+
`rotation`; `scale`). **Dimension-agnostic** and **pure** — they verify
|
|
263
|
+
headlessly. A node carries ONE script, so compose by splitting roles across a
|
|
264
|
+
small subtree (e.g. an enemy with `Chase` on the body and `Health` on a child
|
|
265
|
+
hitbox) and wiring signals.
|
|
266
|
+
|
|
267
|
+
## FollowCamera
|
|
268
|
+
|
|
269
|
+
THE camera behavior. Put it on a `Camera2D`/`Camera3D` (whose `position` is the
|
|
270
|
+
view center/eye) and point `target` at the player — each frame the camera lerps
|
|
271
|
+
toward `target.position + offset`. Works in 2D and 3D.
|
|
272
|
+
|
|
273
|
+
| Prop | Default | Meaning |
|
|
274
|
+
|---|---|---|
|
|
275
|
+
| `target` | `""` | node path to follow (required) |
|
|
276
|
+
| `offset` | `[]` | constant offset added to the target position |
|
|
277
|
+
| `smoothing` | `0` | 0..1 retention: 0 = instant snap, 0.85–0.95 = smooth drag (60fps-independent) |
|
|
278
|
+
| `deadzone` | `0` | don't move until the target drifts this far from the desired point |
|
|
279
|
+
|
|
280
|
+
```json
|
|
281
|
+
{ "name": "Camera", "type": "Camera2D", "props": { "current": true },
|
|
282
|
+
"script": { "name": "FollowCamera", "props": { "target": "/root/Player", "smoothing": 0.9 } } }
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Patrol
|
|
286
|
+
|
|
287
|
+
Walk a node along fixed waypoints at constant `speed` — guards, moving platforms.
|
|
288
|
+
`points` are position arrays OR node paths (read live each frame, so you can
|
|
289
|
+
author markers). Emits `reachedPoint(index)` on arrival.
|
|
290
|
+
|
|
291
|
+
| Prop | Default | Meaning |
|
|
292
|
+
|---|---|---|
|
|
293
|
+
| `points` | `[]` | waypoints: position arrays or node-path strings (required) |
|
|
294
|
+
| `speed` | `60` | units per second along the path |
|
|
295
|
+
| `loop` | `true` | wrap to the first point after the last (loop mode) |
|
|
296
|
+
| `mode` | `"loop"` | `'loop'` wraps · `'pingpong'` reverses at the ends |
|
|
297
|
+
| `pauseAt` | `0` | seconds to wait at each reached point |
|
|
298
|
+
|
|
299
|
+
Signals: `reachedPoint(index)`
|
|
300
|
+
|
|
301
|
+
```json
|
|
302
|
+
{ "name": "Guard", "type": "Node2D",
|
|
303
|
+
"script": { "name": "Patrol", "props": { "points": [[100, 0], [300, 0]], "speed": 80, "mode": "pingpong" } } }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Chase
|
|
307
|
+
|
|
308
|
+
Homing enemy AI: each frame moves toward `target.position` at `speed`, stopping
|
|
309
|
+
within `stopRange` (melee reach). `reachedTarget` fires once on entering range
|
|
310
|
+
(re-arms on leaving); `loseRange` gives up the chase (emits `lostTarget`).
|
|
311
|
+
|
|
312
|
+
| Prop | Default | Meaning |
|
|
313
|
+
|---|---|---|
|
|
314
|
+
| `target` | `""` | node path to chase, resolved from THIS node (required) |
|
|
315
|
+
| `speed` | `60` | units per second |
|
|
316
|
+
| `stopRange` | `0` | stop (and emit reachedTarget) within this distance |
|
|
317
|
+
| `loseRange` | `0` | give up beyond this distance (0 = never lose) |
|
|
318
|
+
| `moveParent` | `false` | move the PARENT node instead of this one |
|
|
319
|
+
|
|
320
|
+
Signals: `reachedTarget` · `lostTarget`
|
|
321
|
+
|
|
322
|
+
`target` is a node-path prop, so it resolves from THIS behavior's node — a
|
|
323
|
+
spawned enemy reaches the player with the absolute `/root/Player` (a bare
|
|
324
|
+
`Player` would look under the enemy).
|
|
325
|
+
|
|
326
|
+
**`moveParent` — Chase + Health on one entity.** A node carries ONE behavior. An
|
|
327
|
+
enemy whose ROOT must hold `Health(freeOnDeath)` (for clone-safe death) puts
|
|
328
|
+
`Chase` on a CHILD `AI` node with `moveParent: true`; the child then drives the
|
|
329
|
+
whole entity toward the target. (Distance is measured from the moved parent.)
|
|
330
|
+
|
|
331
|
+
```json
|
|
332
|
+
{ "name": "Enemy", "type": "Node2D", "groups": ["enemy"],
|
|
333
|
+
"script": { "name": "Chase", "props": { "target": "/root/Player", "speed": 120, "stopRange": 20 } } }
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Wander
|
|
337
|
+
|
|
338
|
+
Seeded random roaming inside a circle around the spawn point — idle critters,
|
|
339
|
+
ambient wildlife. Uses `this.rng`, so a seeded engine wanders identically every
|
|
340
|
+
run (replayable, test-stable).
|
|
341
|
+
|
|
342
|
+
| Prop | Default | Meaning |
|
|
343
|
+
|---|---|---|
|
|
344
|
+
| `speed` | `40` | units per second while roaming |
|
|
345
|
+
| `radius` | `50` | roam radius around the spawn point |
|
|
346
|
+
| `changeEvery` | `2` | seconds before forcibly picking a new destination |
|
|
347
|
+
|
|
348
|
+
```json
|
|
349
|
+
{ "name": "Critter", "type": "Node2D",
|
|
350
|
+
"script": { "name": "Wander", "props": { "speed": 30, "radius": 60, "changeEvery": 1.5 } } }
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## ZombieAI
|
|
354
|
+
|
|
355
|
+
The staple monster AI in ONE behavior: SHAMBLE around on its own, then LOCK ON and
|
|
356
|
+
CHARGE `aggroTarget` (the player) once it wanders within `aggroRange` — giving up
|
|
357
|
+
again past `deAggroRange` (hysteresis, no boundary flicker). Use this instead of
|
|
358
|
+
`Wander`+`Chase` (a node carries ONE behavior, so they can't co-exist) whenever you
|
|
359
|
+
want enemies that idle/roam until the player gets close, then rush in.
|
|
360
|
+
|
|
361
|
+
| Prop | Default | Meaning |
|
|
362
|
+
|---|---|---|
|
|
363
|
+
| `aggroTarget` | `""` | node path to charge once near (usually `/root/Player`) |
|
|
364
|
+
| `aggroRange` | `12` | start charging within this distance |
|
|
365
|
+
| `deAggroRange` | `0` | give up beyond this (0 = `aggroRange` × 1.5) |
|
|
366
|
+
| `chaseSpeed` | `4` | units/second while charging |
|
|
367
|
+
| `wanderSpeed` | `1.4` | units/second while roaming |
|
|
368
|
+
| `wanderRadius` | `8` | roam jitter radius |
|
|
369
|
+
| `wanderChangeEvery` | `3` | seconds before picking a new roam goal |
|
|
370
|
+
| `goalTarget` | `""` | optional node path to DRIFT toward while roaming |
|
|
371
|
+
| `stopRange` | `1.2` | stop (idle) this close to the active target |
|
|
372
|
+
| `moveParent` | `false` | move the PARENT node instead of this one |
|
|
373
|
+
|
|
374
|
+
Signals: `enteredAggro` · `exitedAggro` · `movementStateChanged('idle'|'walk'|'run')`
|
|
375
|
+
|
|
376
|
+
- **Wire `movementStateChanged` to a skin** to swap animation clips — shamble (`walk`)
|
|
377
|
+
while roaming, sprint (`run`) while charging, `idle` when stopped at the target.
|
|
378
|
+
- **`goalTarget` = "advance on the objective".** With it set, the enemy DRIFTS toward
|
|
379
|
+
that node (e.g. a base/obelisk the horde is attacking) while still wandering, so it
|
|
380
|
+
reads as a roaming horde that nonetheless closes on the objective — but peels off to
|
|
381
|
+
CHARGE you when you stray within `aggroRange`. Leave it `""` for pure roam-around-spawn.
|
|
382
|
+
- **`moveParent`** is the same AI-on-a-child pattern as `Chase`: the entity ROOT holds
|
|
383
|
+
`Health(freeOnDeath)`, a child `AI` node carries `ZombieAI(moveParent:true)`.
|
|
384
|
+
- 3D: moves only in the GROUND PLANE (x,z) and leaves the up axis (y) to YOU.
|
|
385
|
+
`aggroRange` is measured on the ground (terrain height ignored). **It does NOT
|
|
386
|
+
auto-fall:** an AI-driven body whose position is set from script is kinematic, so on
|
|
387
|
+
rolling terrain you must ground it yourself each frame (`y = terrain.heightAt(x,z)` —
|
|
388
|
+
see incanto-3d-models.md "Grounding a model"). On a FLAT floor or for a HOVERING enemy
|
|
389
|
+
(drone), keeping the up axis is exactly right — it stays at its spawn height for free.
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
{ "name": "Zombie", "type": "CharacterBody3D", "groups": ["enemy"],
|
|
393
|
+
"script": { "name": "Health", "props": { "max": 30, "freeOnDeath": true } },
|
|
394
|
+
"children": [
|
|
395
|
+
{ "name": "AI", "type": "Node3D", "script": { "name": "ZombieAI",
|
|
396
|
+
"props": { "aggroTarget": "/root/Player", "aggroRange": 11, "chaseSpeed": 5,
|
|
397
|
+
"goalTarget": "/root/Obelisk", "wanderSpeed": 1.6, "moveParent": true } } } ] }
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Driving a model's animation from a custom AI
|
|
401
|
+
|
|
402
|
+
Built-in AIs emit a STATE signal; YOU map it to clips on the skin (the engine never
|
|
403
|
+
guesses your clip names). Two equivalent patterns — both clone-safe because the behavior
|
|
404
|
+
instance is per-entity (so they work for `WaveSpawner` clones, whose scene `connections`
|
|
405
|
+
are NOT cloned):
|
|
406
|
+
|
|
407
|
+
- **(A) self-wire in `onReady`** (what the showcases use — robust for spawned clones):
|
|
408
|
+
```ts
|
|
409
|
+
export class ZombieSkin extends Behavior { // attach to the GLB Skin node
|
|
410
|
+
onReady() {
|
|
411
|
+
const skin = this.node as unknown as ModelInstance3D;
|
|
412
|
+
const ai = this.node.parent?.getNodeOrNull('AI'); // the sibling carrying ZombieAI
|
|
413
|
+
const clips = { idle: '$idle', walk: '$walk', run: '$runFast' };
|
|
414
|
+
ai?.on('movementStateChanged', (s) => { const c = clips[s]; if (c) skin.animation = c; });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
- **(B) scene `connection`** (fine for a single authored entity, NOT for clones):
|
|
419
|
+
`{ "signal": "movementStateChanged", "from": "Enemy/AI", "to": "Enemy/Skin", "handler": "setClip" }`.
|
|
420
|
+
|
|
421
|
+
Same `ZombieSkin` is the place to also FACE the heading and GROUND the body each frame
|
|
422
|
+
(see incanto-3d-character.md "+Z-FORWARD rule" and incanto-3d-models.md "Grounding").
|
|
423
|
+
|
|
424
|
+
## MoveTo
|
|
425
|
+
|
|
426
|
+
Eased position tween from the start to a fixed `to` over `duration` seconds —
|
|
427
|
+
opening doors, sliding platforms, scripted moves. Emits `arrived` once.
|
|
428
|
+
`startOnSignal: true` arms it via `start()` (wire a signal → `start`).
|
|
429
|
+
|
|
430
|
+
| Prop | Default | Meaning |
|
|
431
|
+
|---|---|---|
|
|
432
|
+
| `to` | `[]` | destination position (required) |
|
|
433
|
+
| `duration` | `1` | seconds the move takes |
|
|
434
|
+
| `ease` | `"easeInOut"` | one of `linear` · `easeIn` · `easeOut` · `easeInOut` |
|
|
435
|
+
| `startOnSignal` | `false` | defer until `start()` (default: begin at ready) |
|
|
436
|
+
|
|
437
|
+
Signals: `arrived` — Methods: `start()`
|
|
438
|
+
|
|
439
|
+
```json
|
|
440
|
+
{ "name": "Platform", "type": "Node2D", "props": { "position": [0, 100] },
|
|
441
|
+
"script": { "name": "MoveTo", "props": { "to": [400, 100], "duration": 2, "ease": "easeInOut" } } }
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
## Oscillate
|
|
445
|
+
|
|
446
|
+
Continuous sine motion around the start value — floating platforms, bobbing
|
|
447
|
+
pickups, spinning/pulsing coins. Drives one `axis` of `position`, `rotation`
|
|
448
|
+
(`mode: 'rotation'` — a "spin"), or `scale` (a "pulse"):
|
|
449
|
+
`value = start + amplitude · sin(2π · frequency · t)`.
|
|
450
|
+
|
|
451
|
+
| Prop | Default | Meaning |
|
|
452
|
+
|---|---|---|
|
|
453
|
+
| `axis` | `"y"` | component to drive (`x`/`y`/`z`; 2D rotation is `z`) |
|
|
454
|
+
| `amplitude` | `1` | peak displacement from the baseline |
|
|
455
|
+
| `frequency` | `1` | cycles per second |
|
|
456
|
+
| `mode` | `"position"` | one of `position` · `rotation` · `scale` |
|
|
457
|
+
|
|
458
|
+
```json
|
|
459
|
+
{ "name": "Coin", "type": "Sprite2D",
|
|
460
|
+
"script": { "name": "Oscillate", "props": { "axis": "z", "amplitude": 180, "frequency": 0.5, "mode": "rotation" } } }
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## Spawner
|
|
464
|
+
|
|
465
|
+
Drip-feed clones of a TEMPLATE into the scene on a timer — enemy generators,
|
|
466
|
+
pickup fountains. `prefab` is a node PATH to a template (usually a hidden
|
|
467
|
+
`visible:false` child); each `interval` it's cloned via the same serialize→rebuild
|
|
468
|
+
clone the editor uses (scripts and children come along; the clone gets a fresh
|
|
469
|
+
identity). `max` caps LIVE instances (drops as spawned children free, so it
|
|
470
|
+
refills); `total` caps LIFETIME spawns then emits `finished`.
|
|
471
|
+
|
|
472
|
+
**The template never ticks.** On first resolution the Spawner DETACHES the
|
|
473
|
+
template node from the live tree (holding it only as a clone source), so its
|
|
474
|
+
behaviors never run and it never renders — a `visible:false` template no longer
|
|
475
|
+
walks its `Chase` onto the player. Clones go INTO the tree; the template stays
|
|
476
|
+
out. (Because it's detached, the first clone keeps the template's exact name;
|
|
477
|
+
later clones get `EnemyTemplate2`, `EnemyTemplate3`, …)
|
|
478
|
+
|
|
479
|
+
| Prop | Default | Meaning |
|
|
480
|
+
|---|---|---|
|
|
481
|
+
| `prefab` | `""` | node path of the template to clone (required) |
|
|
482
|
+
| `interval` | `1` | seconds between spawns |
|
|
483
|
+
| `max` | `0` | max LIVE instances (0 = unlimited) |
|
|
484
|
+
| `at` | `[]` | offset added to the spawner position ([x,y(,z)]) |
|
|
485
|
+
| `autoStart` | `true` | begin ticking at ready |
|
|
486
|
+
| `total` | `0` | total LIFETIME spawns (0 = infinite) |
|
|
487
|
+
|
|
488
|
+
Signals: `spawned(node)` · `finished` — Methods: `spawn()` · `start()` · `stop()`
|
|
489
|
+
|
|
490
|
+
```json
|
|
491
|
+
{ "name": "Spawner", "type": "Node2D",
|
|
492
|
+
"script": { "name": "Spawner", "props": { "prefab": "EnemyTemplate", "interval": 1.5, "max": 8 } },
|
|
493
|
+
"children": [
|
|
494
|
+
{ "name": "EnemyTemplate", "type": "Area2D", "groups": ["enemy"], "props": { "visible": false },
|
|
495
|
+
"script": { "name": "Chase", "props": { "target": "/root/Player", "speed": 90 } } }
|
|
496
|
+
] }
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## WaveSpawner
|
|
500
|
+
|
|
501
|
+
Sequenced enemy waves — the survivor / tower-defense backbone. Each `waves` entry
|
|
502
|
+
is `{ prefab, count, interval, delayBefore }`: after `delayBefore` it spawns
|
|
503
|
+
`count` clones (one every `interval`), then waits until they're ALL cleared
|
|
504
|
+
(freed) before the next. Free dying enemies clone-safely with
|
|
505
|
+
**`Health(freeOnDeath: true)` on the enemy root** — NOT a `died → queueFree`
|
|
506
|
+
connection, which never clones onto spawned instances (see Health). `Lifetime`
|
|
507
|
+
also frees timed spawns.
|
|
508
|
+
|
|
509
|
+
| Prop | Default | Meaning |
|
|
510
|
+
|---|---|---|
|
|
511
|
+
| `waves` | `[]` | array of `{ prefab, count, interval, delayBefore }` (required) |
|
|
512
|
+
| `autoStart` | `true` | begin the first wave at ready |
|
|
513
|
+
|
|
514
|
+
Signals: `waveStarted(index)` · `waveCleared(index)` · `allCleared`
|
|
515
|
+
|
|
516
|
+
```json
|
|
517
|
+
{ "name": "Waves", "type": "Node2D",
|
|
518
|
+
"script": { "name": "WaveSpawner", "props": { "waves": [
|
|
519
|
+
{ "prefab": "Grunt", "count": 5, "interval": 0.5, "delayBefore": 1 },
|
|
520
|
+
{ "prefab": "Brute", "count": 2, "interval": 1, "delayBefore": 3 }
|
|
521
|
+
] } },
|
|
522
|
+
"children": [ { "name": "Grunt", "type": "Area2D", "props": { "visible": false } } ] }
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
## Projectile
|
|
526
|
+
|
|
527
|
+
Straight-line, constant-velocity motion — bullets, arrows, thrown rocks. Pure
|
|
528
|
+
MOVEMENT by design: **compose** it with `DamageOnContact` (deal damage), `Lifetime`
|
|
529
|
+
(auto-despawn) and an Area collider on the SAME node — don't conflate them.
|
|
530
|
+
`direction` is a vector OR `'forward'` (from the node `rotation`); it's a
|
|
531
|
+
union-typed prop so its default is `null` (= `'forward'`).
|
|
532
|
+
|
|
533
|
+
| Prop | Default | Meaning |
|
|
534
|
+
|---|---|---|
|
|
535
|
+
| `speed` | `300` | units per second along `direction` |
|
|
536
|
+
| `direction` | `null` | a vector `[x,y(,z)]` OR `'forward'` (`null` = forward) |
|
|
537
|
+
| `gravity` | `0` | constant +y (downward, 2D y-down) pull for arcing shots |
|
|
538
|
+
|
|
539
|
+
```json
|
|
540
|
+
{ "name": "Bullet", "type": "Area2D", "props": { "rotation": 0 },
|
|
541
|
+
"script": { "name": "Projectile", "props": { "speed": 600, "direction": "forward" } } }
|
|
542
|
+
```
|
|
543
|
+
> Compose: add `DamageOnContact` and `Lifetime` to make it actually hurt and
|
|
544
|
+
> despawn. One node carries one script, so attach `Projectile` here and wire the
|
|
545
|
+
> others onto sibling/child Area nodes (or use a Spawner that clones a fully-built
|
|
546
|
+
> bullet template).
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## A complete tiny game (only built-in behaviors + JSON)
|
|
551
|
+
|
|
552
|
+
Player collects 2 coins to win; touching a spike kills them and loses the game.
|
|
553
|
+
Zero custom TypeScript — every node's logic is a built-in behavior, every
|
|
554
|
+
interaction is a `connection`.
|
|
555
|
+
|
|
556
|
+
```json
|
|
557
|
+
{
|
|
558
|
+
"format": 1, "type": "scene", "name": "TinyGame", "dimension": "2d",
|
|
559
|
+
"input": { "move": { "type": "vector2",
|
|
560
|
+
"keys": { "up": ["KeyW"], "down": ["KeyS"], "left": ["KeyA"], "right": ["KeyD"] } } },
|
|
561
|
+
"root": {
|
|
562
|
+
"name": "Game", "type": "Node",
|
|
563
|
+
"script": { "name": "ScoreKeeper", "props": { "scoreToWin": 2, "lives": 1 } },
|
|
564
|
+
"children": [
|
|
565
|
+
{ "name": "Player", "type": "CharacterBody2D", "groups": ["player"],
|
|
566
|
+
"props": { "position": [0, 0], "collider": { "shape": "circle", "radius": 12 } },
|
|
567
|
+
"script": { "name": "Health", "props": { "max": 30 } },
|
|
568
|
+
"children": [{ "name": "Ctl", "type": "CharacterController2D",
|
|
569
|
+
"props": { "mode": "topDown" } }] },
|
|
570
|
+
{ "name": "Coin1", "type": "Area2D", "props": { "position": [80, 0],
|
|
571
|
+
"collider": { "shape": "circle", "radius": 8 } },
|
|
572
|
+
"script": { "name": "Pickup", "props": { "value": 1 } } },
|
|
573
|
+
{ "name": "Coin2", "type": "Area2D", "props": { "position": [160, 0],
|
|
574
|
+
"collider": { "shape": "circle", "radius": 8 } },
|
|
575
|
+
"script": { "name": "Pickup", "props": { "value": 1 } } },
|
|
576
|
+
{ "name": "Spike", "type": "Area2D", "props": { "position": [120, 80],
|
|
577
|
+
"collider": { "shape": "rect", "size": [24, 24] } },
|
|
578
|
+
"script": { "name": "DamageOnContact", "props": { "amount": 40, "oncePerTarget": false } } }
|
|
579
|
+
]
|
|
580
|
+
},
|
|
581
|
+
"connections": [
|
|
582
|
+
{ "signal": "collected", "from": "Coin1", "to": "/root", "handler": "addScore" },
|
|
583
|
+
{ "signal": "collected", "from": "Coin2", "to": "/root", "handler": "addScore" },
|
|
584
|
+
{ "signal": "died", "from": "Player", "to": "/root", "handler": "loseLife" }
|
|
585
|
+
]
|
|
586
|
+
}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Boot it with `createGame2D({ canvas, scene })` — the behaviors are already
|
|
590
|
+
registered. Listen to the win/lose from your page:
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
const game = await createGame2D({ canvas, scene });
|
|
594
|
+
game.scene.root.on('won', () => alert('You win!'));
|
|
595
|
+
game.scene.root.on('lost', () => alert('Game over'));
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Verify it headlessly (no browser) with `incanto/test` — see
|
|
599
|
+
`incanto-verifying-your-game.md`.
|
|
600
|
+
|
|
601
|
+
## The "survivor" recipe (movement + AI + spawn + camera + win/lose, ZERO custom code)
|
|
602
|
+
|
|
603
|
+
A complete top-down survivor that **actually survives a headless run** (mirrored
|
|
604
|
+
by `survivor-game.test.ts`): a `WaveSpawner` drips enemies that `Chase` the
|
|
605
|
+
player; the player's weapon one-shots them and they free + score themselves;
|
|
606
|
+
enemies deal contact damage that drains the player; a `FollowCamera` tracks the
|
|
607
|
+
player; reaching the kill target WINS, dying LOSES. Every node's logic is a
|
|
608
|
+
built-in, every interaction a `connection` — **no custom `Behavior` classes**.
|
|
609
|
+
|
|
610
|
+
The five features that make it compose from the box (and obsolete the custom
|
|
611
|
+
glue an early sim had to hand-write):
|
|
612
|
+
|
|
613
|
+
1. **`DamageOnContact.targetGroup`** — the weapon hits only `enemy`, each enemy
|
|
614
|
+
hitbox hits only `player`. Without it, enemies clustered at spawn kill each
|
|
615
|
+
other before reaching the player.
|
|
616
|
+
2. **`Health.freeOnDeath`** — node-local, so it clones; the dying enemy frees
|
|
617
|
+
ITSELF (no per-clone `died → queueFree` connection, which never clones).
|
|
618
|
+
3. **weapon `dealtDamage → addScore`** — the connection is on the WEAPON (a
|
|
619
|
+
non-cloned player child), so it is clone-safe; scoring per kill just works.
|
|
620
|
+
4. **`Chase.moveParent`** — a node has ONE behavior, so the enemy ROOT holds
|
|
621
|
+
`Health(freeOnDeath)` while a child `AI` carries `Chase(moveParent:true)` and
|
|
622
|
+
drives the whole entity.
|
|
623
|
+
5. **correct paths** — behavior targets are absolute (`/root/Player`, resolved
|
|
624
|
+
from the enemy/camera node); connection `to` the root is `/root` or `.`.
|
|
625
|
+
|
|
626
|
+
```json
|
|
627
|
+
{
|
|
628
|
+
"format": 1, "type": "scene", "name": "Survivor", "dimension": "2d",
|
|
629
|
+
"input": { "move": { "type": "vector2",
|
|
630
|
+
"keys": { "up": ["KeyW"], "down": ["KeyS"], "left": ["KeyA"], "right": ["KeyD"] } } },
|
|
631
|
+
"root": {
|
|
632
|
+
"name": "Game", "type": "Node",
|
|
633
|
+
"script": { "name": "ScoreKeeper", "props": { "scoreToWin": 3, "lives": 1 } },
|
|
634
|
+
"children": [
|
|
635
|
+
{ "name": "Player", "type": "CharacterBody2D", "groups": ["player"],
|
|
636
|
+
"props": { "position": [0, 0], "collider": { "shape": "circle", "radius": 12 } },
|
|
637
|
+
"script": { "name": "Health", "props": { "max": 100 } },
|
|
638
|
+
"children": [
|
|
639
|
+
{ "name": "Ctl", "type": "CharacterController2D", "props": { "mode": "topDown" } },
|
|
640
|
+
{ "name": "Weapon", "type": "Area2D",
|
|
641
|
+
"props": { "collider": { "shape": "circle", "radius": 24 } },
|
|
642
|
+
"script": { "name": "DamageOnContact",
|
|
643
|
+
"props": { "amount": 1, "targetGroup": "enemy" } } }
|
|
644
|
+
] },
|
|
645
|
+
{ "name": "Camera", "type": "Camera2D", "props": { "current": true },
|
|
646
|
+
"script": { "name": "FollowCamera", "props": { "target": "/root/Player", "smoothing": 0.9 } } },
|
|
647
|
+
{ "name": "Spawner", "type": "Node2D",
|
|
648
|
+
"script": { "name": "WaveSpawner", "props": { "waves": [
|
|
649
|
+
{ "prefab": "EnemyTemplate", "count": 3, "interval": 0.6, "delayBefore": 0 }
|
|
650
|
+
] } },
|
|
651
|
+
"children": [
|
|
652
|
+
{ "name": "EnemyTemplate", "type": "CharacterBody2D", "groups": ["enemy"],
|
|
653
|
+
"props": { "position": [300, 0], "visible": false,
|
|
654
|
+
"collider": { "shape": "circle", "radius": 12 } },
|
|
655
|
+
"script": { "name": "Health", "props": { "max": 1, "freeOnDeath": true } },
|
|
656
|
+
"children": [
|
|
657
|
+
{ "name": "AI", "type": "Node2D",
|
|
658
|
+
"script": { "name": "Chase",
|
|
659
|
+
"props": { "target": "/root/Player", "speed": 90, "moveParent": true } } },
|
|
660
|
+
{ "name": "Hit", "type": "Area2D",
|
|
661
|
+
"props": { "collider": { "shape": "circle", "radius": 12 } },
|
|
662
|
+
"script": { "name": "DamageOnContact",
|
|
663
|
+
"props": { "amount": 10, "targetGroup": "player", "oncePerTarget": false } } }
|
|
664
|
+
] }
|
|
665
|
+
] }
|
|
666
|
+
]
|
|
667
|
+
},
|
|
668
|
+
"connections": [
|
|
669
|
+
{ "signal": "dealtDamage", "from": "Player/Weapon", "to": "/root", "handler": "addScore" },
|
|
670
|
+
{ "signal": "died", "from": "Player", "to": "/root", "handler": "loseLife" }
|
|
671
|
+
]
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
> **Role-splitting (one script per node):** the enemy ROOT carries
|
|
676
|
+
> `Health(freeOnDeath)` (so freeing it removes the whole enemy on death); the
|
|
677
|
+
> `AI` child carries `Chase(moveParent:true)` to move that root; the `Hit` child
|
|
678
|
+
> carries the enemy's contact damage. The weapon contacts the enemy root or any
|
|
679
|
+
> child — `DamageOnContact` resolves `Health` from the contacted node, its
|
|
680
|
+
> descendants, then up the ancestor chain, so it finds the entity root's
|
|
681
|
+
> `Health` either way (it won't cross into a sibling entity).
|
|
682
|
+
> The weapon's `dealtDamage(amount, target)` scores `amount` (1) per hit; with
|
|
683
|
+
> 1-HP enemies one hit = one kill = +1; 3 kills → `won`. The player dying →
|
|
684
|
+
> `loseLife` → `lost`.
|
|
685
|
+
|
|
686
|
+
> **Canonical full example:** `examples/star-survivor/` is this recipe grown into
|
|
687
|
+
> a real, playable 2D game (real Area2D/CharacterBody2D nodes, FollowCamera,
|
|
688
|
+
> `Oscillate` spinning star pickups, a `coin` sprite, and a HUD). It is built
|
|
689
|
+
> from these built-ins with ZERO custom gameplay code — the only TypeScript is a
|
|
690
|
+
> tiny `HudUpdater` that paints `ScoreKeeper.score`/`Health.current` into Labels
|
|
691
|
+
> (the gameplay library is renderer-agnostic and intentionally doesn't draw HUD
|
|
692
|
+
> text). Its `verify.ts` proves the win AND lose paths headlessly via
|
|
693
|
+
> `runScript`. Copy it as the starting point for any survivor/arena game.
|
|
694
|
+
|
|
695
|
+
## When to write a custom behavior instead
|
|
696
|
+
|
|
697
|
+
These cover the common scaffolding. Write your own `Behavior` (see
|
|
698
|
+
`incanto-behaviors-and-scripts.md`) for game-specific logic — enemy AI, custom
|
|
699
|
+
movement, puzzle rules — and compose it WITH these via signals (e.g. your AI
|
|
700
|
+
behavior calls `getNode('Player').behavior` ... or simpler, wire your behavior's
|
|
701
|
+
signal into a built-in's method through a `connection`).
|
|
702
|
+
|
|
703
|
+
## Exact contract
|
|
704
|
+
|
|
705
|
+
Generated, always-current prop/signal tables for every behavior live in
|
|
706
|
+
`incanto-node-reference.md` (the "Gameplay behaviors" section).
|
|
707
|
+
```
|