incanto 0.50.0 → 0.52.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/dist/2d.d.ts +38 -2
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +4 -3
- package/dist/3d.js +4 -4
- package/dist/{behavior-62q0HWBO.d.ts → behavior-uPEuZrUB.d.ts} +47 -3
- package/dist/{create-game-BLDjy_PW.js → create-game-B_e9hJf7.js} +92 -10
- package/dist/{create-game-DqqxEax1.js → create-game-CGnoypjL.js} +6 -6
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +1 -1
- package/dist/{duplicate-CI9WF_bg.js → duplicate-B-OtSRFL.js} +1 -1
- package/dist/editor.js +1917 -1579
- package/dist/{environment-presets-XFuqu5jv.js → environment-presets-DSZwsKPs.js} +3 -3
- package/dist/{gameplay-DbaI313d.js → gameplay-B6jqvYeM.js} +163 -6
- package/dist/gameplay.d.ts +79 -2
- package/dist/gameplay.js +2 -2
- package/dist/index.d.ts +74 -5
- package/dist/index.js +7 -7
- package/dist/{loader-DwazzlQb.js → loader-B-Gft32x.js} +372 -23
- package/dist/{loader-CeyU_bm1.d.ts → loader-B9iTqs27.d.ts} +1 -1
- package/dist/net.d.ts +15 -410
- package/dist/net.js +1 -822
- package/dist/{pathfinding-C49JSNNq.d.ts → pathfinding-CAR9DjQQ.d.ts} +1 -1
- package/dist/{physics-2d-_9VBOHn6.js → physics-2d-Dns-oZlE.js} +7 -2
- package/dist/{physics-3d-DrpF5hcG.js → physics-3d-BhI0ehpe.js} +8 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-uvaZj1KX.js → register-DVwlnZAZ.js} +20 -266
- package/dist/{register-BNPZYJmd.js → register-Trx7WHnD.js} +3 -3
- package/dist/{registry-IyWCGe4q.js → registry-C7u42TID.js} +23 -1
- package/dist/{replay-DYdy1wb0.d.ts → replay-BU1CCM15.d.ts} +1 -1
- package/dist/{replay-j-m6lJ4W.js → replay-BicPOMX0.js} +214 -2
- package/dist/split-screen-CZ9ccBBQ.js +1267 -0
- package/dist/split-screen-paxkQs_q.d.ts +441 -0
- package/dist/{src-B3HrKuAi.js → src-5gbZO47I.js} +1 -1
- package/dist/{teardown-BKTCzLek.js → teardown-ks3d5W9n.js} +2 -1
- package/dist/{test-D16igj4C.js → test-DAojuFdb.js} +119 -22
- package/dist/test.d.ts +60 -5
- package/dist/test.js +3 -3
- package/dist/vite.js +2 -2
- package/editor/assets/{agent8-t3kl5q9K.js → agent8-CCvckvbw.js} +1 -1
- package/editor/assets/{debug-Bu3eeAlO.js → debug-CLNCOnbc.js} +1 -1
- package/editor/assets/{index-Df5g8ofT.js → index-Cb5Brupb.js} +99 -92
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/skills/incanto-assets.md +4 -1
- package/skills/incanto-building-2d-games.md +6 -1
- package/skills/incanto-editor.md +48 -11
- package/skills/incanto-gameplay-behaviors.md +14 -5
- package/skills/incanto-multiplayer.md +39 -3
- package/skills/incanto-node-reference.md +13 -0
- package/skills/incanto-save-slots.md +134 -12
- package/skills/incanto-verifying-your-game.md +56 -0
- package/templates/agent8-server.ts +79 -2
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/dist/register-CB11yp21.js +0 -374
package/editor/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Incanto Scene Editor</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%236ee7dc'/><text x='8' y='12' text-anchor='middle' font-size='11' font-family='monospace' fill='%230e1018'>i</text></svg>" />
|
|
8
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-Cb5Brupb.js"></script>
|
|
9
9
|
<link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
package/skills/incanto-assets.md
CHANGED
|
@@ -164,4 +164,7 @@ Two consequences worth knowing:
|
|
|
164
164
|
|
|
165
165
|
A texture that 404s now shows up in `game.assetErrors()` alongside models, by the
|
|
166
166
|
URL you wrote — so "why is my sprite invisible" is answerable without opening the
|
|
167
|
-
network tab.
|
|
167
|
+
network tab. In **2D** the same question is `renderer.assets.errors()`
|
|
168
|
+
(`$ref`, url and reason per failed entry), and the scene EDITOR reads it: a
|
|
169
|
+
failed asset is red in the explorer with the url in its tooltip and the
|
|
170
|
+
consequence in its inspector.
|
|
@@ -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,
|
|
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
|
package/skills/incanto-editor.md
CHANGED
|
@@ -39,13 +39,17 @@ The `scenes` button opens the project as a tree, not a list of paths:
|
|
|
39
39
|
- **The scene you are editing** is marked and revealed, and the cursor starts on it.
|
|
40
40
|
- **A filter box** — type any part of a path; matches show wherever they are hiding.
|
|
41
41
|
Keyboard from that box: `↑↓` move · `→` open a folder · `←` close it · `⏎` load ·
|
|
42
|
-
`Esc` close (backdrop and ✕ work too).
|
|
42
|
+
`Esc` close (backdrop and ✕ work too). The cursor always sits on a SCENE —
|
|
43
|
+
the first match while you filter, the scene you are editing when you open the
|
|
44
|
+
panel — so `⏎` loads something without arrowing first.
|
|
43
45
|
- Each row carries **when it was last written and how big it is**, which is usually
|
|
44
46
|
how you recognise the file you were just in.
|
|
45
47
|
- **create** makes a new scene at the path in the box — left empty it uses the
|
|
46
48
|
placeholder, which tracks the folder you are standing in, so a scene lands beside
|
|
47
49
|
its siblings rather than at the project root. Parent dirs are created.
|
|
48
|
-
- Loading another scene while you have unsaved EDITS asks first
|
|
50
|
+
- Loading another scene while you have unsaved EDITS asks first, and the answer
|
|
51
|
+
is not only *discard*: **save & open** writes the scene you are leaving and
|
|
52
|
+
then opens the other one.
|
|
49
53
|
|
|
50
54
|
**The same browser opens inside a running game.** When the game's dev server serves
|
|
51
55
|
the project's scenes, `☰ debug ▸ ✎ edit this scene` gives you the whole project: the
|
|
@@ -66,7 +70,12 @@ is not there. The editor still edits the scene the game booted with meanwhile.
|
|
|
66
70
|
Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
|
|
67
71
|
|
|
68
72
|
- **Explorer** — two collapsible sections: **ASSETS** on top (icon rows by
|
|
69
|
-
type;
|
|
73
|
+
type; **an asset the renderer could not FETCH turns red**, with the url in its
|
|
74
|
+
tooltip and the consequence spelled out when you select it — *"failed to
|
|
75
|
+
load — every node using $fx/coin draws nothing"* — plus a banner naming all of
|
|
76
|
+
them. A 404'd texture is otherwise the one failure with no symptom: the scene
|
|
77
|
+
is structurally perfect, the tree is full, and the viewport draws nothing where
|
|
78
|
+
the art should be; keys with a `group/` prefix nest under collapsible folders (any depth),
|
|
70
79
|
each showing its recursive asset count — refs are `$group/key`; click the
|
|
71
80
|
icon for a blurb, the row to edit in the inspector; DRAG asset rows onto a
|
|
72
81
|
folder (or the section background = root) to move them — references rewrite
|
|
@@ -79,7 +88,14 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
|
|
|
79
88
|
to reorder before/after. Dragging a selected row moves the whole selection.
|
|
80
89
|
Illegal drops (engine rules — e.g. a CharacterController2D outside a
|
|
81
90
|
CharacterBody2D) are ROLLED BACK entirely with the error in the banner; the
|
|
82
|
-
tree never shows a state the engine would reject.
|
|
91
|
+
tree never shows a state the engine would reject.
|
|
92
|
+
**A reparent does not MOVE anything**: the node keeps its world transform and
|
|
93
|
+
the editor rewrites the local `position`/`rotation`/`scale` to match (drop a
|
|
94
|
+
ball at `[3, 0.6, 0]` onto a crate at `[-3, 0.5, 0]` and the ball stays put,
|
|
95
|
+
holding `[6, 0.1, 0]`). Rotated and scaled parents are handled the same way,
|
|
96
|
+
and the values that are no longer needed disappear rather than being written
|
|
97
|
+
as defaults. This is the Godot/Unity/Blender behaviour, and it is what keeps a
|
|
98
|
+
tree edit from changing the picture. Right-click for
|
|
83
99
|
duplicate / rename (or double-click the name) / cut / copy / paste-as-child /
|
|
84
100
|
delete — all act on the multi-selection. **Rename REPAIRS references**: every
|
|
85
101
|
`connections[].from/to` and every node-path prop pointing at the node (or into
|
|
@@ -121,6 +137,12 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
|
|
|
121
137
|
scales to 0.25 steps (the readout shows the snapped value).
|
|
122
138
|
2D additionally supports click-pick, body drag, wheel zoom-at-cursor,
|
|
123
139
|
right/middle/Shift-drag pan, Alt+wheel scale, and collider wireframes.
|
|
140
|
+
**HUD nodes are editable like anything else**: a `UILayer` subtree is posed in
|
|
141
|
+
screen space rather than world space, and the viewport now picks, outlines and
|
|
142
|
+
drags it there — click the widget where you SEE it, and its position moves 1:1
|
|
143
|
+
with the cursor whatever the world zoom is. (Picking used to look only at the
|
|
144
|
+
world pass, so a HUD could not be selected in the viewport at all, and its
|
|
145
|
+
outline was drawn wherever the game camera happened to be looking.)
|
|
124
146
|
**`F` frames the SELECTED node** (its whole subtree; a light or empty node has
|
|
125
147
|
no bounds, so the camera goes to it at a readable distance) — and the whole
|
|
126
148
|
scene when nothing is selected, which is the Maya/Unity/Unreal meaning of the
|
|
@@ -136,9 +158,25 @@ Opens a local page (default `http://127.0.0.1:5179/`) with three panes:
|
|
|
136
158
|
- **Inspector** — schema-driven from the node registry, with STRUCTURED editors for
|
|
137
159
|
the hard parts: `collider` (shape dropdown + per-shape dimensions, mirrored by
|
|
138
160
|
the wireframe), `network` (mode dropdown + sync-key chips + throttle),
|
|
139
|
-
`script`
|
|
140
|
-
|
|
141
|
-
|
|
161
|
+
`script` — **the behaviors the engine SHIPS are a dropdown**, and picking one
|
|
162
|
+
builds a form from its own prop schema (a `Health` gets `max`/`regenPerSec`/
|
|
163
|
+
`invulnerableFor`/`freeOnDeath` with their defaults; a `Patrol`'s `mode` is a
|
|
164
|
+
`loop`/`pingpong` menu), delta-only like every other field. A name that is NOT
|
|
165
|
+
built in is your game's TypeScript: it keeps the raw-JSON props box and says
|
|
166
|
+
so, with copy-paste Behavior boilerplate in its help — and `groups` (tag
|
|
167
|
+
chips). Every one has a `?` help popover with examples. Values equal to the
|
|
168
|
+
default are removed (delta-only, like the serializer).
|
|
169
|
+
**A prop that holds a NODE PATH** (`Chase.target`, `Camera2D.follow`,
|
|
170
|
+
`Spawner.prefab`, `Joint3D.target`, `skinPath`, `terrain`…) offers every node
|
|
171
|
+
in the scene as a list — `%Name` where the name is unique, the absolute path
|
|
172
|
+
where it repeats — and free text still works for the forms a list cannot
|
|
173
|
+
enumerate (`../Skin`, a path into a subtree). A value that resolves to NOTHING
|
|
174
|
+
is marked red with the reason: `'%Playerr' matches no node in this scene. The
|
|
175
|
+
scene still loads — the prop just does nothing.` That last sentence is the
|
|
176
|
+
point: unlike a connection, a dangling path prop is not a load error, so
|
|
177
|
+
nothing else would ever have told you. A behavior's path props are checked
|
|
178
|
+
exactly like the node's own, and `incanto-check` reports the same thing from
|
|
179
|
+
the file.
|
|
142
180
|
|
|
143
181
|
**3D scenes** get full camera navigation: drag orbits, right/middle/Shift-drag pans,
|
|
144
182
|
wheel zooms, `F` frames the contents, and **`0` / the `game cam` button** returns to the
|
|
@@ -353,8 +391,7 @@ get normal click-select back.
|
|
|
353
391
|
The **✦ button** beside the add-node controls opens the Generate dialog — the
|
|
354
392
|
`incanto/env` generators inside the editor, driven by the same `GENERATORS`
|
|
355
393
|
catalog as the `incanto-env` CLI and filtered to the open scene's dimension
|
|
356
|
-
(3D: arena, terrain,
|
|
357
|
-
platforms2d, maze2d, dungeon2d). The param form is built from the catalog
|
|
394
|
+
(3D: arena, terrain, maze; 2D: platforms2d, maze2d, dungeon2d). The param form is built from the catalog
|
|
358
395
|
metadata — numbers clamp to their min/max, option lists become dropdowns — so
|
|
359
396
|
a new generator needs zero editor changes. The seed starts random (↻ rerolls);
|
|
360
397
|
the same seed always generates the same level. **insert** runs the generator
|
|
@@ -395,7 +432,7 @@ trusts the surrounding network — use it only inside containers.
|
|
|
395
432
|
The EDIT view freezes game time — nothing falls or fires until you press play;
|
|
396
433
|
only ambient visuals (model animations, particles, water, foliage sway) keep
|
|
397
434
|
moving. The PLAY view simulates everything engine-native but cannot execute the
|
|
398
|
-
game's TypeScript behaviors.
|
|
399
|
-
viewport
|
|
435
|
+
game's TypeScript behaviors. Both dimensions have the W/E/R gizmos in the
|
|
436
|
+
viewport; 2D additionally has click-pick, body drag and wheel zoom-at-cursor. Runtime-injected textures (asset URLs
|
|
400
437
|
like `"GENERATED_AT_RUNTIME"`) render as a magenta checkerboard — position/size
|
|
401
438
|
stay visible; the real art appears in the running game.
|
|
@@ -58,11 +58,20 @@ Two kinds of node path resolve from DIFFERENT origins — mixing them up is the
|
|
|
58
58
|
enemy (a child of the Spawner) reaches the player with the ABSOLUTE
|
|
59
59
|
`/root/Player`, never a bare `Player` (which would look under the enemy).
|
|
60
60
|
|
|
61
|
-
Renaming a node in the editor
|
|
62
|
-
|
|
63
|
-
still
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
Renaming a node in the editor REWRITES both kinds for you (behavior props
|
|
62
|
+
included) and reports what it changed, and deleting a node lists every reference
|
|
63
|
+
that still points at it before it goes. Hand-edited JSON is covered too:
|
|
64
|
+
**`bunx incanto-check` now reports a node-path prop that leads nowhere** —
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
|
|
68
|
+
The scene loads and the prop does nothing.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
— which is the one failure that used to be completely silent (a prop path
|
|
72
|
+
resolves with `getNodeOrNull`, so the scene opens and the enemy just never
|
|
73
|
+
chases). `Chase.target` and `FollowCamera.target` are `required`: an EMPTY one
|
|
74
|
+
is a load error naming the node, not a surprise in the browser.
|
|
66
75
|
|
|
67
76
|
**Never write `/root/<RootName>/...`.** If your root node is named `Game`, the
|
|
68
77
|
path is `/root/Player` — NOT `/root/Game/Player`: `/root/` already *is* the
|
|
@@ -58,13 +58,22 @@ Authority rules:
|
|
|
58
58
|
applies onto the spawned scene's root; `position` lerps when `interpolate: true`
|
|
59
59
|
(remote entities render slightly in the past — that's correct). Emits
|
|
60
60
|
`spawned(node, key)` / `despawned(node, key)`. `source: "collection:<id>"` mirrors a
|
|
61
|
-
room collection by `__id
|
|
61
|
+
room collection by `__id` — a collection ENTITY applies as your server wrote it
|
|
62
|
+
(`addCollectionItem('coins', { position: [x, y] })` lands on the spawned node;
|
|
63
|
+
the `{sync: {…}}` envelope an owner state uses is accepted too). A key whose
|
|
64
|
+
node PATH resolves to nothing (`Skin.animation` on a scene with no `Skin`) is
|
|
65
|
+
reported once rather than dropped — that value replicates to nowhere.
|
|
62
66
|
|
|
63
67
|
## Boot
|
|
64
68
|
|
|
65
69
|
```ts
|
|
70
|
+
import { registerNodes2D } from 'incanto/2d'; // or registerNodes3D for a 3D game
|
|
66
71
|
import { NetworkManager, registerNodesNet, LoopbackHub } from 'incanto/net';
|
|
67
72
|
|
|
73
|
+
// REGISTRARS COMPOSE and you need BOTH: `registerNodesNet()` adds NetworkSpawner
|
|
74
|
+
// and nothing else, so a 2D scene loaded after it alone dies on its own root
|
|
75
|
+
// node (`Unknown node type 'Node2D'`).
|
|
76
|
+
registerNodes2D();
|
|
68
77
|
registerNodesNet();
|
|
69
78
|
const scene = loadScene(json);
|
|
70
79
|
engine.setScene(scene);
|
|
@@ -147,8 +156,12 @@ the whole N-panel harness — one LocalGameServer, one engine + NetworkManager
|
|
|
147
156
|
per player — so you only supply the per-panel renderer/input:
|
|
148
157
|
|
|
149
158
|
```ts
|
|
159
|
+
import { registerNodes2D } from 'incanto/2d'; // the dimension your scene uses
|
|
150
160
|
import { createSplitScreen, registerNodesNet } from 'incanto/net';
|
|
151
161
|
|
|
162
|
+
registerNodes2D();
|
|
163
|
+
registerNodesNet();
|
|
164
|
+
|
|
152
165
|
const canvases = [document.getElementById('p1'), document.getElementById('p2')];
|
|
153
166
|
const { players, server, dispose } = await createSplitScreen({
|
|
154
167
|
scene: gameJson, // shared scene (each panel gets its own copy)
|
|
@@ -163,8 +176,13 @@ const { players, server, dispose } = await createSplitScreen({
|
|
|
163
176
|
});
|
|
164
177
|
```
|
|
165
178
|
|
|
166
|
-
|
|
167
|
-
|
|
179
|
+
Each panel gets physics on the same terms `createGame2D` gives it (`'auto'`:
|
|
180
|
+
Rapier when the scene has bodies; `physics: false` opts out) — without it a
|
|
181
|
+
`CharacterBody2D` never moves. The first panel's clock pumps `server.tick` (so
|
|
182
|
+
`$roomTick` runs) — don't add your own. Every panel joins ONE room: a shared scene usually says
|
|
183
|
+
`multiplayer: { room: "auto" }`, and "auto" means a server-ASSIGNED room, so the
|
|
184
|
+
harness pins panels 1..N to the room panel 0 got (`room: 'lobby'` overrides).
|
|
185
|
+
`dispose()` tears every panel down. Going live is unchanged: ONE
|
|
168
186
|
client per browser with `createAgent8Server()` as the transport.
|
|
169
187
|
|
|
170
188
|
It runs the SAME class body the cloud runs: the v2 globals (`$sender`/`$global`/
|
|
@@ -172,6 +190,24 @@ It runs the SAME class body the cloud runs: the v2 globals (`$sender`/`$global`/
|
|
|
172
190
|
(so `this.*` never persists), and calls are serialized (no global leaks across
|
|
173
191
|
`await`s). `$roomTick(deltaMS, roomId)` runs only while a room has users.
|
|
174
192
|
|
|
193
|
+
**Verifying a whole match**: `playMultiplayer` from `incanto/test` runs N clients
|
|
194
|
+
against one in-memory server for a fixed number of simulated seconds and reports
|
|
195
|
+
what they ended up sharing — rooms, who saw whom, what each `NetworkSpawner`
|
|
196
|
+
materialised, per-client frame errors, the final room state. See
|
|
197
|
+
`incanto-verifying-your-game.md`.
|
|
198
|
+
|
|
199
|
+
**Driving one by hand**: server calls are QUEUED and only run when the event loop
|
|
200
|
+
turns, so a synchronous frame loop enqueues a thousand ticks that never execute —
|
|
201
|
+
the match clock stands still and every `call()` result arrives after your
|
|
202
|
+
assertions.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
for (let f = 0; f * 16.7 < ms; f++) {
|
|
206
|
+
for (const p of players) p.engine.tick(f * 16.7);
|
|
207
|
+
if (f % 6 === 0) await new Promise((r) => setTimeout(r, 0)); // let the server run
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
175
211
|
It is a FUNCTIONAL emulator, NOT the platform: no isolated-vm sandbox, no rate
|
|
176
212
|
limits, and no DURABLE persistence (global state lives only for the preview process
|
|
177
213
|
— it is not saved across runs, and rooms still clear when empty). It proves your
|
|
@@ -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 |
|
|
@@ -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
|
|
86
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
141
|
-
them are wrong —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{ "
|
|
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.
|
|
@@ -619,6 +619,23 @@ rows until the orange box lands on the thing you're hunting.
|
|
|
619
619
|
inside a `static` subtree, HUD widgets outside a HudLayer. Programmatic:
|
|
620
620
|
`auditScene(json)` from `incanto` or `incanto/test` returns the warnings.
|
|
621
621
|
|
|
622
|
+
**Including a node path that points at nothing.** `Chase.target`,
|
|
623
|
+
`Camera2D.follow`, `Spawner.prefab`, `Joint3D.target`, `skinPath`, `terrain` —
|
|
624
|
+
these hold a path to another node, and a wrong one is SILENT: a connection that
|
|
625
|
+
dangles is a hard load error, but a prop resolves with `getNodeOrNull`, so the
|
|
626
|
+
scene opens and the enemy simply never chases.
|
|
627
|
+
|
|
628
|
+
```
|
|
629
|
+
warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
|
|
630
|
+
The scene loads and the prop does nothing.
|
|
631
|
+
warn: World/Cam: follow — '/Level/Player' starts at 'Level', but the scene
|
|
632
|
+
root is 'World' (or write '/root/…'). The scene loads and the prop does nothing.
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
An empty value is never reported — `""` is the default of most of these and
|
|
636
|
+
means "not set". A behavior of YOUR OWN is not reported either: the checker
|
|
637
|
+
never loads your TypeScript, so it cannot know which of its props are paths.
|
|
638
|
+
|
|
622
639
|
## Deterministic replay (record once, regression-test forever)
|
|
623
640
|
|
|
624
641
|
The engine is fully deterministic under a seed + injected clock, so a
|
|
@@ -644,6 +661,45 @@ never Math.random, dt/`engine.time` never Date.now. Gamepads replay through
|
|
|
644
661
|
the ACTIONS they were bound to, not raw pad state.
|
|
645
662
|
|
|
646
663
|
|
|
664
|
+
## Multiplayer: do the players end up in the SAME world?
|
|
665
|
+
|
|
666
|
+
Every rung above asks about one player. A multiplayer game's first question is
|
|
667
|
+
whether there are two of them in the same room at all — and when there are not,
|
|
668
|
+
nothing throws, nothing logs, and each browser looks perfectly fine on its own.
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
import { playMultiplayer, multiplayText } from 'incanto/test';
|
|
672
|
+
import { Server } from '../server/src/server'; // your real server class
|
|
673
|
+
|
|
674
|
+
const report = await playMultiplayer({
|
|
675
|
+
scene: gameJson,
|
|
676
|
+
server: Server, // optional: runs YOUR rules
|
|
677
|
+
scenes: { 'remote-player': remoteJson },
|
|
678
|
+
players: 2,
|
|
679
|
+
seconds: 5,
|
|
680
|
+
drive: ({ engine, manager, account }, frame) => {
|
|
681
|
+
engine.input.setActionVector('move', 1, 0); // steer each client
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
console.log(multiplayText(report));
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
```
|
|
688
|
+
multiplayer: 2 clients, 5s, room room1
|
|
689
|
+
p1: sees [p2] spawned Remotes=1
|
|
690
|
+
p2: sees [p1] spawned Remotes=1
|
|
691
|
+
✓ every client in one room, seeing the others
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
`report.problems` names the silent ones: clients in different rooms, a client
|
|
695
|
+
that never saw another account's state, a `NetworkSpawner` that materialised
|
|
696
|
+
nothing, frame errors per client. `report.roomState` is the shared state at the
|
|
697
|
+
end, so a `$roomTick` match clock is checkable too.
|
|
698
|
+
|
|
699
|
+
It runs the whole match on ONE in-memory server (`LocalGameServer`), so it also
|
|
700
|
+
pays the trap a hand-rolled harness has to know about: those server calls are
|
|
701
|
+
QUEUED, and a synchronous frame loop enqueues a thousand ticks that never run.
|
|
702
|
+
|
|
647
703
|
## The fifth question: is it a GAME?
|
|
648
704
|
|
|
649
705
|
The four signals above answer *will it load*, *did something throw*, *did the art
|
|
@@ -32,27 +32,104 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
// These globals are injected by the agent8 isolated-vm runtime (see gameserver-sdk-v2).
|
|
35
|
-
declare const $sender: { account: string; roomId: string };
|
|
35
|
+
declare const $sender: { account: string; roomId: string; isGuest?: boolean };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Room membership, PERSISTENT global state, global collections, room management
|
|
39
|
+
* and global messaging. Room data is ephemeral — anything that must outlive an
|
|
40
|
+
* empty room is written here.
|
|
41
|
+
*/
|
|
36
42
|
declare const $global: {
|
|
37
43
|
joinRoom(roomId?: string): Promise<string>;
|
|
38
44
|
leaveRoom(): Promise<string>;
|
|
45
|
+
getGlobalState(): Promise<Record<string, unknown>>;
|
|
46
|
+
updateGlobalState(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
47
|
+
getMyState(): Promise<Record<string, unknown>>;
|
|
48
|
+
updateMyState(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
49
|
+
getUserState(account: string): Promise<Record<string, unknown>>;
|
|
50
|
+
updateUserState(account: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
51
|
+
addCollectionItem(collectionId: string, item: Record<string, unknown>): Promise<{ __id: string }>;
|
|
52
|
+
updateCollectionItem(
|
|
53
|
+
collectionId: string,
|
|
54
|
+
item: Record<string, unknown>,
|
|
55
|
+
): Promise<{ __id: string }>;
|
|
56
|
+
deleteCollectionItem(collectionId: string, itemId: string): Promise<{ __id: string }>;
|
|
57
|
+
deleteCollection(collectionId: string): Promise<string>;
|
|
58
|
+
getCollectionItem(collectionId: string, itemId: string): Promise<Record<string, unknown>>;
|
|
59
|
+
getCollectionItems(
|
|
60
|
+
collectionId: string,
|
|
61
|
+
options?: CollectionQuery,
|
|
62
|
+
): Promise<Record<string, unknown>[]>;
|
|
63
|
+
countCollectionItems(collectionId: string, options?: CollectionQuery): Promise<number>;
|
|
64
|
+
countRooms(): Promise<number>;
|
|
65
|
+
getAllRoomIds(): Promise<string[]>;
|
|
66
|
+
getAllRoomStates(): Promise<Record<string, unknown>[]>;
|
|
67
|
+
getRoomUserAccounts(roomId: string): Promise<string[]>;
|
|
68
|
+
countRoomUsers(roomId: string): Promise<number>;
|
|
69
|
+
getRoomState(roomId: string): Promise<Record<string, unknown>>;
|
|
70
|
+
updateRoomState(roomId: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
71
|
+
getRoomUserState(roomId: string, account: string): Promise<Record<string, unknown>>;
|
|
72
|
+
updateRoomUserState(
|
|
73
|
+
roomId: string,
|
|
74
|
+
account: string,
|
|
75
|
+
patch: Record<string, unknown>,
|
|
76
|
+
): Promise<Record<string, unknown>>;
|
|
77
|
+
broadcastToAll(type: string, message: unknown): void;
|
|
78
|
+
sendMessageToUser(account: string, type: string, message: unknown): void;
|
|
39
79
|
};
|
|
80
|
+
|
|
81
|
+
/** Query options for a collection read (`filters` / `orderBy` / `limit`). */
|
|
82
|
+
interface CollectionQuery {
|
|
83
|
+
filters?: Record<string, unknown>;
|
|
84
|
+
orderBy?: { field: string; direction?: 'asc' | 'desc' };
|
|
85
|
+
limit?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The CURRENT room: shared state, per-user state, collections, messaging. */
|
|
40
89
|
declare const $room: {
|
|
90
|
+
getMyState(): Promise<Record<string, unknown>>;
|
|
41
91
|
updateMyState(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
92
|
+
getRoomState(): Promise<Record<string, unknown>>;
|
|
42
93
|
updateRoomState(patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
43
94
|
getUserState(account: string): Promise<Record<string, unknown>>;
|
|
44
95
|
updateUserState(account: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
96
|
+
getAllUserStates(): Promise<Record<string, unknown>[]>;
|
|
97
|
+
countUsers(): Promise<number>;
|
|
45
98
|
addCollectionItem(collectionId: string, item: Record<string, unknown>): Promise<{ __id: string }>;
|
|
46
99
|
updateCollectionItem(
|
|
47
100
|
collectionId: string,
|
|
48
101
|
item: Record<string, unknown>,
|
|
49
102
|
): Promise<{ __id: string }>;
|
|
50
103
|
deleteCollectionItem(collectionId: string, itemId: string): Promise<{ __id: string }>;
|
|
104
|
+
deleteCollection(collectionId: string): Promise<string>;
|
|
105
|
+
getCollectionItem(collectionId: string, itemId: string): Promise<Record<string, unknown>>;
|
|
106
|
+
getCollectionItems(
|
|
107
|
+
collectionId: string,
|
|
108
|
+
options?: CollectionQuery,
|
|
109
|
+
): Promise<Record<string, unknown>[]>;
|
|
110
|
+
countCollectionItems(collectionId: string, options?: CollectionQuery): Promise<number>;
|
|
51
111
|
broadcastToRoom(type: string, message: unknown): void;
|
|
52
|
-
|
|
112
|
+
sendMessageToUser(account: string, type: string, message: unknown): void;
|
|
53
113
|
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Serialize a read-modify-write against concurrent requests.
|
|
117
|
+
*
|
|
118
|
+
* The preview runs calls one at a time, so a FORGOTTEN lock still passes
|
|
119
|
+
* locally — live, parallel requests race (double-award, last-write-wins).
|
|
120
|
+
*/
|
|
54
121
|
declare function $lock<T>(key: string, fn: () => T | Promise<T>): Promise<T>;
|
|
55
122
|
|
|
123
|
+
/** Per-account currency ledger. `burn`/`transfer` throw on an insufficient balance. */
|
|
124
|
+
declare const $asset: {
|
|
125
|
+
mint(assetId: string, amount: number): Promise<Record<string, unknown>>;
|
|
126
|
+
burn(assetId: string, amount: number): Promise<Record<string, unknown>>;
|
|
127
|
+
has(assetId: string, amount: number): Promise<boolean>;
|
|
128
|
+
get(assetId: string): Promise<number>;
|
|
129
|
+
getAll(): Promise<Record<string, number>>;
|
|
130
|
+
transfer(toAccount: string, assetId: string, amount: number): Promise<Record<string, unknown>>;
|
|
131
|
+
};
|
|
132
|
+
|
|
56
133
|
export class Server {
|
|
57
134
|
// ---- rooms -----------------------------------------------------------------
|
|
58
135
|
|