incanto 0.49.0 → 0.51.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 (48) hide show
  1. package/bin/incanto-play.mjs +42 -17
  2. package/dist/2d.d.ts +3 -2
  3. package/dist/2d.js +3 -3
  4. package/dist/3d.d.ts +4 -3
  5. package/dist/3d.js +4 -4
  6. package/dist/{behavior-62q0HWBO.d.ts → behavior-rZNfzVbH.d.ts} +37 -3
  7. package/dist/{create-game-DFBjMetZ.js → create-game-BNaOC3Px.js} +5 -5
  8. package/dist/{create-game-BgV6UbVA.js → create-game-CkzKZ4v5.js} +5 -5
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-CI9WF_bg.js → duplicate-EybyYeyL.js} +1 -1
  11. package/dist/{environment-presets-CZOH5TY5.js → environment-presets-C08pOC6H.js} +2 -2
  12. package/dist/{gameplay-02Btmmjn.js → gameplay-bStgtZBV.js} +175 -6
  13. package/dist/gameplay.d.ts +79 -2
  14. package/dist/gameplay.js +2 -2
  15. package/dist/index.d.ts +14 -5
  16. package/dist/index.js +6 -6
  17. package/dist/{loader-DwazzlQb.js → loader-B2asghWa.js} +350 -1
  18. package/dist/{loader-CeyU_bm1.d.ts → loader-Dkbn56KC.d.ts} +1 -1
  19. package/dist/net.d.ts +1 -1
  20. package/dist/net.js +3 -3
  21. package/dist/{pathfinding-C49JSNNq.d.ts → pathfinding-Bz34pvQD.d.ts} +1 -1
  22. package/dist/{physics-2d-vyCBfACH.js → physics-2d-Ccq2L9R0.js} +8 -3
  23. package/dist/{physics-3d-DpRqw8Mz.js → physics-3d-B2c5KNYh.js} +13 -8
  24. package/dist/react.d.ts +1 -1
  25. package/dist/react.js +1 -1
  26. package/dist/{register-BNPZYJmd.js → register-B_BaaGx-.js} +2 -2
  27. package/dist/{register-uvaZj1KX.js → register-CGq-Hee8.js} +19 -265
  28. package/dist/{register-CB11yp21.js → register-CLVhzWcI.js} +2 -2
  29. package/dist/{replay-C0XJIsO7.js → replay-BgKxGcXH.js} +42 -3
  30. package/dist/{replay-CAphXMyM.d.ts → replay-DIP2_as4.d.ts} +6 -2
  31. package/dist/{src-DF4gCsqO.js → src-C3UwzYXl.js} +1 -1
  32. package/dist/{test-DRna_BQU.js → test-D2gRpk5V.js} +48 -21
  33. package/dist/test.d.ts +3 -3
  34. package/dist/test.js +2 -2
  35. package/dist/vite.js +2 -2
  36. package/editor/assets/{agent8-DCW4TgDt.js → agent8-DbX_msaO.js} +1 -1
  37. package/editor/assets/{debug-RC6qts6S.js → debug-C-kMxdl1.js} +1 -1
  38. package/editor/assets/{index-5dEIhvsf.js → index-CUc1U7wm.js} +91 -91
  39. package/editor/index.html +1 -1
  40. package/package.json +1 -1
  41. package/skills/incanto-building-2d-games.md +6 -1
  42. package/skills/incanto-gameplay-behaviors.md +11 -0
  43. package/skills/incanto-node-reference.md +13 -0
  44. package/skills/incanto-playtesting.md +13 -2
  45. package/skills/incanto-save-slots.md +134 -12
  46. package/templates-app/beacon-isle-3d/package.json +1 -1
  47. package/templates-app/tps-3d/package.json +1 -1
  48. package/templates-app/village-quest-3d/package.json +1 -1
package/editor/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Incanto Scene Editor</title>
7
7
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%236ee7dc'/><text x='8' y='12' text-anchor='middle' font-size='11' font-family='monospace' fill='%230e1018'>i</text></svg>" />
8
- <script type="module" crossorigin src="./assets/index-5dEIhvsf.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-CUc1U7wm.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incanto",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -213,8 +213,13 @@ listing the valid set. With a viewport design, UI coordinates are design px.
213
213
  ```
214
214
  `setScene` frees the old root, clears + redeclares the input map from the new
215
215
  scene's `input{}`, and emits `sceneChanged` — `createGame2D`'s touch overlay
216
- rebuilds itself on that signal, and the renderer loads the new scene's assets
216
+ rebuilds itself on that signal, the PHYSICS world registers the new scene's
217
+ bodies before its first frame, and the renderer loads the new scene's assets
217
218
  on demand. Register any extra behaviors/node types BEFORE the `loadScene` call.
219
+
220
+ The input map is CLEARED by the swap, which matters for a headless drive: an
221
+ injected `setActionVector` does not carry into the next level — set it again
222
+ after the transition, the same way a player's held key is re-read.
218
223
  - **Game over / restart**: swap to a fresh load of the SAME JSON —
219
224
  `engine.setScene(loadScene(levelJson))`. `loadScene` treats the JSON as
220
225
  read-only (everything it keeps is cloned), so reloading the same imported
@@ -356,6 +356,17 @@ whole entity toward the target. (Distance is measured from the moved parent.)
356
356
  "script": { "name": "Chase", "props": { "target": "/root/Player", "speed": 120, "stopRange": 20 } } }
357
357
  ```
358
358
 
359
+ **A character body chases through the level, not through walls.** When the node
360
+ being moved is a `CharacterBody2D`/`3D`, `Chase` (and `Patrol`) move it with
361
+ `moveAndSlide()`, so it collides and slides like any character. A plain
362
+ `Node2D`/`Node3D` — the example above — has no collider and keeps the direct
363
+ write, which is also what you want for a ghost or a flying marker.
364
+
365
+ This is straight-line homing, not pathfinding: a body can now be STOPPED by
366
+ geometry it used to walk through. When enemies must find their way around a
367
+ level, that is what `buildTerrainNav` + `findPath` + `PathFollow` are for (see
368
+ `incanto-building-3d-games.md`).
369
+
359
370
  ## Wander
360
371
 
361
372
  Seeded random roaming inside a circle around the spawn point — idle critters,
@@ -1422,6 +1422,19 @@ Signals: `collected(value, other)`
1422
1422
  | `direction` | `null` | null |
1423
1423
  | `gravity` | `0` | number |
1424
1424
 
1425
+ ### `SavePoint`
1426
+
1427
+ | Prop | Default | Kind |
1428
+ |---|---|---|
1429
+ | `game` | `"game"` | string |
1430
+ | `slot` | `"1"` | string |
1431
+ | `label` | `""` | string |
1432
+ | `scene` | `""` | string |
1433
+ | `restoreOnReady` | `false` | boolean |
1434
+ | `probeOnReady` | `false` | boolean |
1435
+
1436
+ Signals: `saved` · `restored` · `noSave` · `hasSave`
1437
+
1425
1438
  ### `ScoreKeeper`
1426
1439
 
1427
1440
  | Prop | Default | Kind |
@@ -98,12 +98,23 @@ far more useful.
98
98
  ## Replays
99
99
 
100
100
  A run that did not win is written out. Because the engine is deterministic
101
- (seeded RNG, injected clock), a replay reproduces that run **bit-identically**:
101
+ (seeded RNG, injected clock), a replay reproduces that run **bit-identically** —
102
+ feed the file straight back in, with the seed the run used:
102
103
 
103
104
  ```bash
104
- bunx incanto-play src/game.scene.json --seed 7 # then replay the recording
105
+ bunx incanto-play src/game.scene.json --seed 1 --commands .incanto/playtest/fell-seed1.json
106
+ # {"ok":true,"cmd":"replay","frames":804,"events":963}
107
+ # … then the scene as it ended: /Game/Player position=[28.9, -49.46, …]
105
108
  ```
106
109
 
110
+ `--commands` takes either a text command script or a replay file and tells them
111
+ apart by content. The seed matters: the world's own randomness (spawners,
112
+ wandering AI) comes from it, and only the PLAYER's input is in the recording.
113
+
114
+ Then read where it ended. The player above left a walled 40×40 arena at
115
+ `x = 28.9` before falling — a thing you cannot learn from "fell in 1/4" and can
116
+ read straight off the replay.
117
+
107
118
  One file per *kind* of failure — twenty identical "stuck" replays teach nothing
108
119
  the first one does not.
109
120
 
@@ -47,6 +47,17 @@ restored.** You resume at the scene's start with stats, inventory, unlocks and
47
47
  quest flags intact — a checkpoint save. If your game needs a position, save it:
48
48
  `serialize()` returns anything.
49
49
 
50
+ **What the run CONSUMED is remembered.** Reloading from the file brings back
51
+ every gem you already picked up, which would let a collect-five-to-win run
52
+ resume at four with five gems on the map. So the save also carries the authored
53
+ uids that are no longer in the tree, under `#freed`, and the restore frees them
54
+ again — collectibles, opened chests, destroyed crates, a named boss. Spawned
55
+ clones never have uids (`duplicateNode` drops them on purpose), so the ledger is
56
+ exactly the authored world.
57
+
58
+ Those nodes go on the **next frame** (`queueFree`, not an immediate detach), so
59
+ read `report.freed` rather than counting children the instant restore returns.
60
+
50
61
  ## Making a behavior saveable
51
62
 
52
63
  Two optional hooks, exactly like the other five:
@@ -82,8 +93,17 @@ lives, won/lost), `Collector` (total).
82
93
 
83
94
  The uid is the join key, because it is the one identifier that survives a rename
84
95
  or a reparent. The editor assigns one to every node it touches. A hand-written
85
- scene may not have them — `engine.captureState()` logs any node that has state to
86
- save and no uid to key it under.
96
+ scene may not have them, and then the save is silently empty — so
97
+ **`incanto-check` warns about it**, naming each node, long before you write a
98
+ save:
99
+
100
+ ```
101
+ warn: these carry state a save keeps and have no uid to key it under, so the
102
+ save comes back EMPTY and the load reports no problem: Game (ScoreKeeper),
103
+ Game/Player (Health). Give each one a "uid" from newUid().
104
+ ```
105
+
106
+ At runtime `engine.captureState()` logs the same thing per node.
87
107
 
88
108
  Never hand-craft a uid. Use `newUid()`.
89
109
 
@@ -120,7 +140,68 @@ It never throws. A save naming a uid this build deleted reports it in
120
140
  `report.missing` and restores everything else; refusing to load would mean a
121
141
  patch that moves one node deletes everyone's progress.
122
142
 
123
- ## A load menu
143
+ ## Many levels: the router is three lines, and they are yours
144
+
145
+ The engine does not route scenes — deliberately, because only your game knows
146
+ what a key means. What it does is record the key, so the routing is a lookup:
147
+
148
+ ```ts
149
+ import { createGame2D, loadScene } from 'incanto';
150
+ import level1 from './level1.scene.json';
151
+ import level2 from './level2.scene.json';
152
+
153
+ const SCENES: Record<string, unknown> = { level1, level2 }; // key → scene JSON
154
+
155
+ // New game
156
+ const game = await createGame2D({ canvas, scene: SCENES.level1 });
157
+
158
+ // Next level — the SavePoint in the new scene writes `level2` from here on
159
+ (game.scene.root.getNode('Flow').behavior as GameFlow).goToScene(SCENES.level2);
160
+
161
+ // Continue
162
+ const slot = new SaveSlots('chapters').read('1');
163
+ const scene = SCENES[slot?.scene ?? 'level1'];
164
+ const game = await createGame2D({ canvas, scene });
165
+ // then `restoreOnReady: true` on that scene's SavePoint, or call restore()
166
+ ```
167
+
168
+ A `SavePoint` records `scene` as the scene's own `name` unless you set the prop,
169
+ so `level2.scene.json` named `level2` needs no wiring at all. Set `scene`
170
+ explicitly when one file is entered more than one way (`"chapter-2-rescue"`).
171
+
172
+ **The swap clears the input map** (the new scene declares its own `input{}`), and
173
+ physics registers the new bodies before that scene's first frame — so a
174
+ character walks in level two exactly as it did in level one.
175
+
176
+ ## A title screen, in JSON
177
+
178
+ A `SavePoint` can ASK without loading. `probeOnReady` fires on the first frame
179
+ and emits `hasSave(label, playtime, scene)` or `noSave`, so the menu wires
180
+ itself:
181
+
182
+ ```json
183
+ { "name": "Save", "type": "Node", "uid": "n_…",
184
+ "script": { "name": "SavePoint",
185
+ "props": { "game": "chapters", "slot": "1", "probeOnReady": true } } }
186
+ ```
187
+ ```json
188
+ { "signal": "noSave", "from": "Save", "to": "HUD/Menu/Continue", "handler": "hide" },
189
+ { "signal": "noSave", "from": "Save", "to": "HUD/Menu/SlotInfo", "handler": "hide" },
190
+ { "signal": "hasSave", "from": "Save", "to": "HUD/Menu/SlotInfo", "handler": "setText" }
191
+ ```
192
+
193
+ With `"format": "Continue: {}"` on that `UiText`, a fresh install shows a menu
194
+ with no Continue button and a save shows `Continue: Chapter 2`. Every HUD widget
195
+ takes `show`/`hide` from a wire (`visible` is a prop, and a connection needs a
196
+ method — the same wall `setText` broke through).
197
+
198
+ The label leads because that is what a menu shows; an unlabelled slot falls back
199
+ to its scene key, so the line is never blank.
200
+
201
+ **The button's press is still yours**, and rightly: `pressed → your router`. See
202
+ the three lines above.
203
+
204
+ ## Several slots
124
205
 
125
206
  ```ts
126
207
  for (const slot of slots.all()) { // newest first
@@ -130,23 +211,64 @@ slots.remove('2');
130
211
  slots.clear(); // "delete all data"
131
212
  ```
132
213
 
214
+ One `SavePoint` per slot is the declarative version: three nodes with
215
+ `slot: "1" | "2" | "3"`, each probing into its own row of the menu.
216
+
133
217
  ## Checking your coverage
134
218
 
135
219
  ```ts
136
- import { behaviorsWithoutSave } from 'incanto';
137
- console.log(behaviorsWithoutSave(game.engine.scene.root));
220
+ import { behaviorsWithoutSave, savesWithoutUid } from 'incanto';
221
+ console.log(behaviorsWithoutSave(game.engine.scene.root)); // forgot serialize?
222
+ console.log(savesWithoutUid(game.engine.scene.root)); // forgot the uid?
138
223
  ```
139
224
 
140
- Names every behavior in the tree that has props and no `serialize`. Not all of
141
- them are wrong — a behavior that derives everything from time has nothing to
225
+ `behaviorsWithoutSave` names every behavior with props and no `serialize`. Not
226
+ all of them are wrong — one that derives everything from time has nothing to
142
227
  save — but it is the list to read before shipping.
143
228
 
144
- ## Autosave
229
+ `savesWithoutUid` is the other half, and none of it is debatable: a behavior
230
+ that DOES serialize, on a node with no uid, is state that goes nowhere.
231
+ `incanto-check` catches the built-ins it can recognise from the JSON
232
+ (`Health`, `ScoreKeeper`, `Collector`); a scene file cannot be asked whether
233
+ YOUR behavior serializes, so this walks the live tree and names those too.
145
234
 
146
- There is no autosave node, on purpose: *when* to save is a design decision
147
- (checkpoint, level end, every 60s, on quit) and only your game knows. Wire it to
148
- whatever signal marks the moment:
235
+ ## Saving from the scene — `SavePoint`
149
236
 
237
+ *When* to save is a design decision (checkpoint, level end, on quit) and only
238
+ your game knows — so the scene still chooses, by picking which signal to wire.
239
+ What it does not need any more is a method of your own to wire it to:
240
+
241
+ ```json
242
+ { "name": "Save", "type": "Node", "uid": "n_…",
243
+ "script": { "name": "SavePoint",
244
+ "props": { "game": "vault", "slot": "1", "label": "Chapter 2" } } }
245
+ ```
150
246
  ```json
151
- { "from": "Level/Exit", "signal": "triggerEnter", "to": "Game", "handler": "onCheckpoint" }
247
+ { "signal": "triggerEnter", "from": "Level/Exit", "to": "Save", "handler": "save" },
248
+ { "signal": "collected", "from": "Gems/Gem1", "to": "Save", "handler": "save" },
249
+ { "signal": "won", "from": ".", "to": "Save", "handler": "save" }
152
250
  ```
251
+
252
+ | prop | default | meaning |
253
+ | --- | --- | --- |
254
+ | `game` | `"game"` | slot namespace — keeps two games on one origin apart |
255
+ | `slot` | `"1"` | which slot this node reads and writes |
256
+ | `label` | `""` | shown in a load menu |
257
+ | `scene` | `""` | the key a loader routes back to (empty = this scene's `name`) |
258
+ | `restoreOnReady` | `false` | read the slot on the first frame — a "Continue" boot |
259
+
260
+ Methods: `save()` · `restore()` · `clear()` — and `playtime` / `slotScene()` to
261
+ read. Signals: `saved(slot)` · `restored(count)` · **`noSave`**, which is what
262
+ greys out a Continue button.
263
+
264
+ `restoreOnReady` lands on the **first frame**, not in `onReady`: `onReady` runs
265
+ children-first, so restoring there would hand the score keeper its state back
266
+ and then watch the root's own `onReady` set it to zero.
267
+
268
+ **A checkpoint wired to `collected` counts that pickup.** `Pickup` queues its
269
+ free before it announces, so a save taken from the handler records the world
270
+ without it — otherwise the gem came back on the next run with the score that
271
+ counted it already banked.
272
+
273
+ Still your code when the moment is not a signal — every 60 s, on `visibilitychange`,
274
+ on a quit button: call `engine.captureState()` and `slots.write()` yourself.
@@ -14,7 +14,7 @@
14
14
  "@dimforge/rapier2d-compat": "0.19.3",
15
15
  "@dimforge/rapier3d-compat": "0.19.3",
16
16
  "@pixiv/three-vrm": "^3.5.3",
17
- "incanto": "^0.49.0",
17
+ "incanto": "^0.51.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.49.0",
16
+ "incanto": "^0.51.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.49.0",
16
+ "incanto": "^0.51.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {