incanto 0.69.0 → 0.71.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/bin/_behaviors-loader.mjs +22 -0
- package/bin/_read-json.mjs +28 -0
- package/bin/incanto-assets.mjs +5 -4
- package/bin/incanto-check.mjs +58 -31
- package/bin/incanto-editor.mjs +128 -5
- package/bin/incanto-env.mjs +3 -2
- package/bin/incanto-feel.mjs +24 -13
- package/bin/incanto-frame.mjs +8 -1
- package/bin/incanto-multiplay.mjs +11 -9
- package/bin/incanto-new.mjs +105 -10
- package/bin/incanto-play.mjs +158 -16
- package/bin/incanto-playtest.mjs +66 -23
- package/bin/incanto-skills.mjs +14 -2
- package/bin/incanto-verify.mjs +121 -102
- package/bin/incanto.mjs +3 -2
- package/dist/2d.d.ts +296 -34
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +157 -9
- package/dist/3d.js +8 -8
- package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
- package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
- package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
- package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
- package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +2 -3
- package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
- package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
- package/dist/editor.js +1088 -882
- package/dist/env.d.ts +1 -1
- package/dist/env.js +5 -3
- package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
- package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
- package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
- package/dist/gameplay.d.ts +254 -10
- package/dist/gameplay.js +2 -2
- package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
- package/dist/index.d.ts +179 -13
- package/dist/index.js +10 -12
- package/dist/json-CfTjpvW8.js +67 -0
- package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
- package/dist/net.d.ts +27 -3
- package/dist/net.js +2 -2
- package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
- package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
- package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
- package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
- package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
- package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
- package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
- package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
- package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
- package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
- package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
- package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
- package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
- package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
- package/dist/test-CmN54kWv.js +4264 -0
- package/dist/test.d.ts +639 -30
- package/dist/test.js +3 -3
- package/dist/touch-BnCyPA0G.js +519 -0
- package/dist/vite.d.ts +20 -37
- package/dist/vite.js +177 -7
- package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
- package/editor/assets/agent8-yLIEYHbd.js +1 -0
- package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
- package/editor/assets/index-DK9xMGpW.js +11046 -0
- package/editor/index.html +1 -1
- package/package.json +4 -14
- package/schemas/scene.schema.json +34 -3
- package/skills/incanto-3d-character.md +14 -1
- package/skills/incanto-3d-models.md +12 -0
- package/skills/incanto-assets.md +9 -1
- package/skills/incanto-audio.md +8 -2
- package/skills/incanto-behaviors-and-scripts.md +105 -7
- package/skills/incanto-building-2d-games.md +97 -10
- package/skills/incanto-building-3d-games.md +58 -4
- package/skills/incanto-editor.md +46 -7
- package/skills/incanto-environment.md +19 -1
- package/skills/incanto-game-feel.md +70 -0
- package/skills/incanto-gameplay-behaviors.md +172 -20
- package/skills/incanto-hud.md +142 -7
- package/skills/incanto-localization.md +13 -5
- package/skills/incanto-multiplayer.md +83 -3
- package/skills/incanto-node-reference.md +239 -59
- package/skills/incanto-performance.md +52 -0
- package/skills/incanto-physics-and-input.md +123 -24
- package/skills/incanto-playtesting.md +112 -2
- package/skills/incanto-save-slots.md +217 -7
- package/skills/incanto-scene-json-authoring.md +69 -12
- package/skills/incanto-verifying-your-game.md +207 -10
- package/skills/incanto-web-integration.md +28 -0
- package/skills/incanto-your-first-game.md +3 -1
- package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
- package/templates-app/beacon-isle-3d/package.json +2 -2
- package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
- package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
- package/templates-app/beacon-isle-3d/src/main.ts +11 -1
- package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
- package/templates-app/beacon-isle-3d/verify.ts +3 -1
- package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
- package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
- package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
- package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
- package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
- package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
- package/templates-app/molehill-2d/index.html +73 -0
- package/templates-app/molehill-2d/package.json +23 -0
- package/templates-app/molehill-2d/src/behaviors.ts +198 -0
- package/templates-app/molehill-2d/src/game.scene.json +1205 -0
- package/templates-app/molehill-2d/src/main.ts +41 -0
- package/templates-app/molehill-2d/tsconfig.json +13 -0
- package/templates-app/molehill-2d/verify.ts +247 -0
- package/templates-app/molehill-2d/vite.config.ts +12 -0
- package/templates-app/platformer-2d/package.json +2 -2
- package/templates-app/platformer-2d/src/game.scene.json +178 -986
- package/templates-app/platformer-2d/src/main.ts +11 -1
- package/templates-app/platformer-2d/tsconfig.json +1 -1
- package/templates-app/star-survivor/package.json +2 -2
- package/templates-app/star-survivor/src/game.scene.json +43 -216
- package/templates-app/star-survivor/src/main.ts +11 -1
- package/templates-app/star-survivor/tsconfig.json +1 -1
- package/templates-app/tps-3d/PROJECT/Context.md +1 -1
- package/templates-app/tps-3d/package.json +2 -2
- package/templates-app/tps-3d/src/behaviors.ts +19 -1
- package/templates-app/tps-3d/src/game.scene.json +66 -263
- package/templates-app/tps-3d/src/main.ts +13 -2
- package/templates-app/tps-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
- package/templates-app/village-quest-3d/package.json +2 -2
- package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
- package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
- package/templates-app/village-quest-3d/src/main.ts +11 -1
- package/templates-app/village-quest-3d/src/village.scene.json +202 -863
- package/templates-app/village-quest-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/verify.ts +14 -1
- package/dist/duplicate-Dj4L-5AA.js +0 -22
- package/dist/json-CwwhxQgb.js +0 -36
- package/dist/registry-CF70EArN.js +0 -212
- package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
- package/dist/test-D6ISmuFE.js +0 -2383
- package/dist/touch-DEAmqGdf.js +0 -225
- package/editor/assets/agent8-BDvaZMQJ.js +0 -1
- package/editor/assets/index-CZIRr5EL.js +0 -10958
package/skills/incanto-hud.md
CHANGED
|
@@ -25,6 +25,11 @@ Declare your HUD in the scene JSON — no `index.html` markup, no CSS, no
|
|
|
25
25
|
works identically over the 2D and 3D renderers and is a silent no-op in
|
|
26
26
|
headless tests (core nodes, no three.js). Widgets pick one of 9 anchors:
|
|
27
27
|
`topLeft top topRight left center right bottomLeft bottom bottomRight`.
|
|
28
|
+
|
|
29
|
+
**Camel case here, hyphens in `UILayer`.** The 2D world-space layer spells the
|
|
30
|
+
same nine `top-left`/`bottom-right`, and neither prop accepts the other's
|
|
31
|
+
spelling — both fail at load listing their own set, so you are one edit from
|
|
32
|
+
right. Worth knowing before you spend the edit.
|
|
28
33
|
Widgets stacked on the same anchor form a column.
|
|
29
34
|
|
|
30
35
|
## Driving widgets from the scene JSON (no TypeScript)
|
|
@@ -36,10 +41,15 @@ are `connections[]` entries — not a behavior whose whole job is one assignment
|
|
|
36
41
|
"connections": [
|
|
37
42
|
{ "signal": "scoreChanged", "from": ".", "to": "HUD/Score", "handler": "setText" },
|
|
38
43
|
{ "signal": "healthChanged", "from": "Player", "to": "HUD/Hp", "handler": "setValue" },
|
|
39
|
-
{ "signal": "
|
|
44
|
+
{ "signal": "livesChanged", "from": ".", "to": "HUD/Lives", "handler": "setText" }
|
|
40
45
|
]
|
|
41
46
|
```
|
|
42
47
|
|
|
48
|
+
**`livesChanged`, not `lifeLost`, for the counter.** `lifeLost` is the EVENT — a
|
|
49
|
+
life was just spent, flash the screen, respawn — and it does not fire when a
|
|
50
|
+
SAVE is loaded, so a counter wired to it still read `3` after a Continue at one
|
|
51
|
+
life left. `livesChanged` is the COUNT, and fires on both.
|
|
52
|
+
|
|
43
53
|
```json
|
|
44
54
|
{ "name": "Score", "type": "UiText",
|
|
45
55
|
"props": { "anchor": "topRight", "text": "Gems 0 / 8", "format": "Gems {} / 8" } }
|
|
@@ -93,9 +103,58 @@ banner.show('YOU DIED', { color: '#ef4444', seconds: 0 }); // sticky until next
|
|
|
93
103
|
the queue; signal `bannerShown(text)`. Props: `size` (font px), `seconds`
|
|
94
104
|
(default duration).
|
|
95
105
|
|
|
106
|
+
**Showing and hiding a widget is `visible`, not a method.** There is no
|
|
107
|
+
`hide()`/`show()` pair on a widget: `UiBanner.show(text)` announces a LINE, and
|
|
108
|
+
every widget's own visibility is the ordinary `visible` prop it inherits from
|
|
109
|
+
every other node —
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
(this.node.getNode('%Pause') as UiPanel).visible = false; // a whole menu, and
|
|
113
|
+
// everything under it
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A panel hides its children with it, so one write takes a whole screen away.
|
|
117
|
+
(HUD widgets are screen-space, so `framing()` leaves them out entirely — asking
|
|
118
|
+
whether the camera can see one is a category error.)
|
|
119
|
+
|
|
96
120
|
Pair with the `Health` / `ScoreKeeper` gameplay behaviors: listen to their
|
|
97
121
|
signals and write the widget props — that is the whole HUD wiring.
|
|
98
122
|
|
|
123
|
+
## On a phone
|
|
124
|
+
|
|
125
|
+
The nine anchors keep out of the device's own furniture: each is 16 px from its
|
|
126
|
+
edge **plus** that edge's `env(safe-area-inset-*)`. Every scaffolded
|
|
127
|
+
`index.html` carries `viewport-fit=cover` — which is what makes a game fill a
|
|
128
|
+
phone edge to edge, and what would otherwise put `anchor: "bottom"` under the
|
|
129
|
+
home indicator, where iOS takes the gesture and your button never hears the tap.
|
|
130
|
+
`topRight` behind the notch and `left`/`right` under the rounded corners are the
|
|
131
|
+
same story in landscape, which is how a phone is held for a game.
|
|
132
|
+
|
|
133
|
+
The insets are ADDED, and every desktop browser reports 0 for all four, so
|
|
134
|
+
nothing moves where there is nothing to avoid. (The touch-control overlay has
|
|
135
|
+
done this since 0.42; the HUD, which is the overlay with the buttons you
|
|
136
|
+
actually tap, joined it much later.)
|
|
137
|
+
|
|
138
|
+
**A widget authored in pixels still fits the screen.** `UiPanel`, `UiBar`,
|
|
139
|
+
`UiImage`, `UiSlider` and `UiDialogue` all cap at `calc(100vw - 32px)` — a pause
|
|
140
|
+
panel at `width: 520` on a 390 px phone would otherwise hang 130 px off the
|
|
141
|
+
edge, taking its buttons with it, and a panel too TALL scrolls rather than
|
|
142
|
+
hiding the row that says Resume.
|
|
143
|
+
|
|
144
|
+
**Every tappable widget is at least 44x44.** Measured in Chrome, a `UiButton`
|
|
145
|
+
used to come out 131x37 and a dialogue choice ~50x29, while the engine's own
|
|
146
|
+
touch buttons have been 64x64 and its stick 120x120 for years — the surface a
|
|
147
|
+
finger actually lands on was the one nobody had sized for a finger. Apple asks
|
|
148
|
+
44, Material 48, WCAG 2.5.5 44. It is a MINIMUM: a button with more text stays
|
|
149
|
+
as wide as its text, and an authored `width` still wins.
|
|
150
|
+
|
|
151
|
+
The layer also takes the GESTURES over itself: every widget that receives taps
|
|
152
|
+
sets `touch-action: none`, and the layer turns off text selection, the iOS
|
|
153
|
+
long-press callout and the tap flash. `claimCanvasGestures` has done that for
|
|
154
|
+
the canvas for a while — but the HUD is a separate overlay ABOVE it, and a
|
|
155
|
+
swipe that starts on the score at the top of the screen never reaches the
|
|
156
|
+
canvas at all. It used to scroll the page or pull-to-refresh.
|
|
157
|
+
|
|
99
158
|
## Interactive widgets: UiButton & UiDialogue
|
|
100
159
|
|
|
101
160
|
```json
|
|
@@ -120,6 +179,18 @@ reveals the line then advances; choice lines render buttons and wait for
|
|
|
120
179
|
`choose(i)`. Buttons opt into pointer events — the rest of the HUD stays
|
|
121
180
|
click-through.
|
|
122
181
|
|
|
182
|
+
**Driving them with no screen** — a HUD widget is DOM, so a headless harness
|
|
183
|
+
cannot click one. Both take the gesture directly:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
(hud.getNode('Start') as UiButton).press(); // emits `pressed`, as a click would
|
|
187
|
+
(hud.getNode('Talk') as UiDialogue).advance(); // reveal the rest, then next line
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
That is how a title screen, a pause menu or a conversation gets tested in
|
|
191
|
+
`runScript` — `click` reaches world nodes through the picker, and a widget is
|
|
192
|
+
not one of those.
|
|
193
|
+
|
|
123
194
|
## Menus, options and inventories (UiPanel + the value widgets)
|
|
124
195
|
|
|
125
196
|
The HUD widgets can SAY things. These arrange them and take a value back — which
|
|
@@ -151,13 +222,34 @@ so structure in the tree becomes structure on screen. Panels nest.
|
|
|
151
222
|
| `UiSlider` | `label`, `value`, `min`, `max`, `step`, `width`, `color` | `changed(value)` |
|
|
152
223
|
| `UiToggle` | `label`, `value` | `changed(bool)` |
|
|
153
224
|
| `UiSelect` | `label`, `options` (`"low,medium,high"`), `value` | `changed(value)` |
|
|
154
|
-
| `UiVolumeSlider` | `bus` (`master`/`sfx`/`music`), plus every `UiSlider` prop | `changed(value)` |
|
|
155
|
-
| `UiMuteToggle` | `label` | `changed(bool)` |
|
|
156
225
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
226
|
+
`UiSelect.value` must be one of its `options` — a value the list cannot show is
|
|
227
|
+
a hard load error naming them, because the browser would show the FIRST option
|
|
228
|
+
while the node went on answering with the value it was given, and `changed`
|
|
229
|
+
only fires when a person moves it. (Writing one at runtime is reported rather
|
|
230
|
+
than refused: a behaviour may widen `options` a frame later, and it goes quiet
|
|
231
|
+
when it does.) `""` is "nothing chosen yet", which is a choice.
|
|
232
|
+
### The ones that need no TypeScript at all
|
|
233
|
+
|
|
234
|
+
| node | props | follows |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| `UiVolumeSlider` | `bus` (`master`/`sfx`/`music`), plus every `UiSlider` prop | `engine.audio[bus]` |
|
|
237
|
+
| `UiMuteToggle` | `label` | `engine.audio.muted` |
|
|
238
|
+
| `UiQualitySelect` | — | `engine.settings` `quality` |
|
|
239
|
+
| `UiFrameCapSelect` | — | `engine.settings` `maxFps` |
|
|
240
|
+
| `UiRenderScaleSelect` | — | `engine.settings` `renderScale` |
|
|
241
|
+
| `UiLanguageSelect` | — | `engine.locale` (options are the scene's own locales) |
|
|
242
|
+
|
|
243
|
+
These are **already wired** in both directions: they show what the game is set
|
|
244
|
+
to, and setting one changes the game. A whole settings screen is those six nodes
|
|
245
|
+
and no TypeScript.
|
|
246
|
+
|
|
247
|
+
**They follow the engine HEADLESS too**, so a harness can drive one: set
|
|
248
|
+
`slider.value`, step, and assert `engine.audio.master` moved. These used to live
|
|
249
|
+
on the painting side of the widget, below `update`'s early
|
|
250
|
+
return for "no DOM element" — so every one of them was inert in `runScript`,
|
|
251
|
+
`incanto-playtest` and `incanto-verify`, showing a `UiSlider`'s 0.5 default next
|
|
252
|
+
to a master volume of 1.
|
|
161
253
|
|
|
162
254
|
**An inventory is a grid panel**: `"layout": "grid", "columns": 5`, one child per
|
|
163
255
|
slot, each a small `UiPanel` holding a `UiImage` (`tint` greys out what you
|
|
@@ -167,6 +259,20 @@ widget use `UiButton`'s `pressed`.
|
|
|
167
259
|
Setting `.value` from a behavior updates the control and does **NOT** re-emit
|
|
168
260
|
`changed` — restoring a saved setting must not fire the handler that saved it.
|
|
169
261
|
|
|
262
|
+
**`choose(value)` is the other half**: set it as a PERSON would, and say so.
|
|
263
|
+
`UiButton` has always had `press()`; the value widgets had nothing, so a preset
|
|
264
|
+
button, a "reset to defaults", a tutorial that moves a slider for you, or a
|
|
265
|
+
harness checking its own options screen had no public route at all.
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
(hud.getNode('%Volume') as UiSlider).choose(0.25); // clamped, emits changed
|
|
269
|
+
(hud.getNode('%Invert') as UiToggle).choose(true);
|
|
270
|
+
(hud.getNode('%Quality') as UiSelect).choose('high'); // an option it does not
|
|
271
|
+
// offer is refused
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Both are silent when nothing changes.
|
|
275
|
+
|
|
170
276
|
**Every prop is live.** `color`, `size`, `width`, `background`, `label` and
|
|
171
277
|
`anchor` are re-read each frame, so flashing the score red, growing a health bar
|
|
172
278
|
or moving a widget to another corner all work from a behavior — and from the
|
|
@@ -204,12 +310,25 @@ What activation MEANS is per widget: a button presses, a toggle flips (left/righ
|
|
|
204
310
|
sets it explicitly, which reads better on a pad), a select walks its list, a
|
|
205
311
|
slider nudges by one `step`.
|
|
206
312
|
|
|
313
|
+
**A `GameFlow` pause panel arms it for you** — that screen IS a menu, it knows
|
|
314
|
+
exactly when it opens and closes, and it puts the layer's own value back on
|
|
315
|
+
resume. Every game with a pause menu was otherwise writing the same two lines,
|
|
316
|
+
or shipping a menu that answered only to a mouse.
|
|
317
|
+
|
|
207
318
|
**It is OFF by default**, deliberately: a game whose HUD happens to contain a
|
|
208
319
|
button must not lose its arrow keys the moment one exists. Turn it on for the
|
|
209
320
|
screens that ARE menus and off again when play resumes —
|
|
210
321
|
`hud.focusNavigation = false`. `hud.focus(widget)` sets the starting item, since
|
|
211
322
|
opening a menu should land somewhere rather than nowhere.
|
|
212
323
|
|
|
324
|
+
**With two layers, the TOPMOST armed one takes the key** — last in the tree,
|
|
325
|
+
which is the one drawn on top, and the one a modal is. Keyboard focus is
|
|
326
|
+
singular by nature (a browser has one focused element); when both a HUD and a
|
|
327
|
+
modal were armed, a single Enter activated the focused widget in EACH, so the
|
|
328
|
+
pause menu's RESUME and whatever the screen underneath had focused both fired.
|
|
329
|
+
A HIDDEN layer is not armed at all, so a closed menu never eats the key from the
|
|
330
|
+
HUD it covers.
|
|
331
|
+
|
|
213
332
|
The gamepad codes are the ones the engine already produces from a pad
|
|
214
333
|
(`Pad12`–`Pad15` d-pad, `Pad0` = A), so nothing extra is declared.
|
|
215
334
|
|
|
@@ -244,6 +363,22 @@ A drop on nothing cancels, and so does putting a thing back into the slot it
|
|
|
244
363
|
came from: that is not a move, and firing `dropped` for it would make every
|
|
245
364
|
mis-grab look like a transfer.
|
|
246
365
|
|
|
366
|
+
**A harness can make the gesture.** This is the one screen the paragraph above
|
|
367
|
+
says a click cannot express, and for a long time it was also the one screen no
|
|
368
|
+
headless check could reach at all — every one of the four signals lived on a
|
|
369
|
+
DOM listener, so `runScript`, `incanto-playtest` and every rung of the ladder
|
|
370
|
+
were blind to an inventory.
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
{ atMs: 200, do: (ctx) => (ctx.getNode('%Potion') as UiImage)
|
|
374
|
+
.dropOnto(ctx.getNode('%SlotB') as UiPanel) }
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
`dropOnto(target)` is to a drag what `press()` is to a click: the same rules as
|
|
378
|
+
the pointer path — a non-`draggable` source does nothing, a target that is not a
|
|
379
|
+
`dropTarget` cancels, and putting a thing back where it came from is not a move
|
|
380
|
+
— and it returns whether the drop was taken.
|
|
381
|
+
|
|
247
382
|
|
|
248
383
|
|
|
249
384
|
## More than one language
|
|
@@ -64,7 +64,7 @@ A text prop becomes translatable by naming a key with `@t:` — the same shape a
|
|
|
64
64
|
the `"$assetKey"` references you already write:
|
|
65
65
|
|
|
66
66
|
```json
|
|
67
|
-
{ "name": "Start", "type": "UiButton", "props": { "text": "@t:menu.start" } }
|
|
67
|
+
{ "name": "Start", "type": "UiButton", "props": { "text": "@t:menu.start" } },
|
|
68
68
|
{ "name": "Wave", "type": "UiText", "props": { "format": "@t:hud.wave" } }
|
|
69
69
|
```
|
|
70
70
|
|
|
@@ -101,10 +101,18 @@ param is left alone rather than blanked.
|
|
|
101
101
|
|
|
102
102
|
### Authoring the table
|
|
103
103
|
|
|
104
|
-
The scene header
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
The scene header has a **strings** table: one row per key, one column per
|
|
105
|
+
language, `+ key` and `+ language`. Two things it shows that the JSON cannot:
|
|
106
|
+
|
|
107
|
+
- an **untranslated cell** shows the English it will silently fall back to,
|
|
108
|
+
greyed — that fallback is the design, not an error, and this is where you see
|
|
109
|
+
it happening;
|
|
110
|
+
- a key the **base locale is missing** is marked red, because that is the one
|
|
111
|
+
case that really breaks: nothing to fall back to, so the raw key paints.
|
|
112
|
+
|
|
113
|
+
Deleting a key a prop still says asks first, and tells you how many props will
|
|
114
|
+
start painting the key. The raw-JSON `strings` field is still in **advanced**
|
|
115
|
+
for pasting a whole table in at once.
|
|
108
116
|
|
|
109
117
|
## Checking a translation without a browser
|
|
110
118
|
|
|
@@ -34,6 +34,16 @@ Authority rules:
|
|
|
34
34
|
- movement/cosmetics → **owner-authoritative** (the replication below)
|
|
35
35
|
- score/economy/inventory → **server-function-authoritative** (extend `server.js`;
|
|
36
36
|
`$sender.account` is trustworthy, args are NOT; guard with `$lock`)
|
|
37
|
+
|
|
38
|
+
> **`$lock` is NOT reentrant.** Taking one key inside another's callback —
|
|
39
|
+
> `$lock('clock')` inside `$lock('match')`, one string apart in a
|
|
40
|
+
> read-modify-write — DEADLOCKS. The preview server runs every client's calls
|
|
41
|
+
> one at a time, so one stuck call wedges the whole match: every
|
|
42
|
+
> `setMyState` stops, new players can never join, and `local.tick()` never
|
|
43
|
+
> resolves. It says so now (`a call has been running for over 5s and every
|
|
44
|
+
> other client is blocked behind it`) instead of hanging in silence, and
|
|
45
|
+
> `incanto-multiplay` prints its verdict instead of exiting 13 with nothing.
|
|
46
|
+
> Take one lock, do the whole read-modify-write inside it, and release.
|
|
37
47
|
- physics/AI run client-side; one-simulator needs (NPC waves) use the host-client pattern
|
|
38
48
|
|
|
39
49
|
## Scene JSON
|
|
@@ -61,7 +71,9 @@ working game's. All three are hard `BAD_FORMAT` errors now, and
|
|
|
61
71
|
— write ["position"] (on 'Player')
|
|
62
72
|
```
|
|
63
73
|
|
|
64
|
-
Valid keys: `mode` (`owner`
|
|
74
|
+
Valid keys: `mode` (`owner` — the only one the engine reads; `observer` was a
|
|
75
|
+
sketch that shipped in the validator's list and was never implemented, so it is
|
|
76
|
+
now refused by name), `sync` (array of prop names),
|
|
65
77
|
`throttleMs` (number). An `owner` with an empty `sync` is an error too — it is
|
|
66
78
|
a half-finished edit that behaves exactly like a broken one.
|
|
67
79
|
|
|
@@ -76,7 +88,11 @@ a half-finished edit that behaves exactly like a broken one.
|
|
|
76
88
|
keyframe (`keyframeMs`, default 2 s) comes round, which is also what heals a
|
|
77
89
|
send that never reached the wire. Spawned entities (bullets,
|
|
78
90
|
pickups) go through **collections**, never extra owner nodes.
|
|
79
|
-
- **`NetworkSpawner`** (register with `registerNodesNet()`): `
|
|
91
|
+
- **`NetworkSpawner`** (register with `registerNodesNet()`): **`scene` is REQUIRED** —
|
|
92
|
+
the key you registered the remote's scene under. Without one the spawner used to
|
|
93
|
+
return from every frame and no remote ever appeared, on a scene that loaded,
|
|
94
|
+
audited and played clean. A spawner in a game with no multiplayer at all says so
|
|
95
|
+
after two seconds, by name. `source: "users"` spawns one
|
|
80
96
|
instance of the registered scene per OTHER account (self skipped); the flat `sync` patch
|
|
81
97
|
applies onto the spawned scene's root; `position` lerps when `interpolate: true`
|
|
82
98
|
(remote entities render slightly in the past — that's correct). Emits
|
|
@@ -98,7 +114,10 @@ import { NetworkManager, registerNodesNet, LoopbackHub } from 'incanto/net';
|
|
|
98
114
|
// node (`Unknown node type 'Node2D'`).
|
|
99
115
|
registerNodes2D();
|
|
100
116
|
registerNodesNet();
|
|
101
|
-
|
|
117
|
+
// `{ engine }` on a MANUAL boot, always: `onReady` runs during the load, and a
|
|
118
|
+
// behaviour that reads `this.engine`/`this.rng` there (Wander draws its first
|
|
119
|
+
// heading in it) throws TREE_VIOLATION without it.
|
|
120
|
+
const scene = loadScene(json, { engine });
|
|
102
121
|
engine.setScene(scene);
|
|
103
122
|
|
|
104
123
|
// DEV / offline / split-screen: zero infrastructure, built-in protocol only
|
|
@@ -142,6 +161,31 @@ delta-merged ARRAY `[{...state, account, __updated}]` → an account-keyed Recor
|
|
|
142
161
|
`subscribeRoomCollection` arrives as `{items:[{__id,…}], changes}` → a `__id`-keyed
|
|
143
162
|
Record. It also re-issues every subscription after a reconnect.
|
|
144
163
|
|
|
164
|
+
**When the socket drops it keeps trying** — 0.5s, 1, 2, 4, then 8 up to eight
|
|
165
|
+
attempts — and SAYS so on the first failure and again if it gives up:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
[incanto] the multiplayer connection dropped and the server would not take it
|
|
169
|
+
back (ECONNREFUSED). Retrying — other players cannot see this one until it
|
|
170
|
+
comes back.
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
There used to be one attempt and no catch, so a server that was still
|
|
174
|
+
restarting rejected it, the rejection escaped an unawaited `void reconnect()`,
|
|
175
|
+
the re-issue loop never ran and nothing scheduled a second try: **the session
|
|
176
|
+
never came back, and nothing said a word.** A `connect()` that resolved `false`
|
|
177
|
+
was taken for success and re-issued every subscription onto a dead socket.
|
|
178
|
+
|
|
179
|
+
**A replication send that fails now reaches `stats().errors`.** It was
|
|
180
|
+
`void this.setMyState(…)`, so the rejection went to the host's
|
|
181
|
+
unhandled-rejection channel — not `engine.log`, not the error count. A client
|
|
182
|
+
whose socket died kept playing perfectly on its own screen while everyone else
|
|
183
|
+
watched it frozen, and the run reported `errors: 0`.
|
|
184
|
+
|
|
185
|
+
**A transport that REFUSES the connection now throws** `NETWORK_UNAVAILABLE`
|
|
186
|
+
out of `NetworkManager.create`, instead of returning a fully-formed manager
|
|
187
|
+
with `connected === false` that had joined nothing.
|
|
188
|
+
|
|
145
189
|
## Writing a custom transport (any backend)
|
|
146
190
|
|
|
147
191
|
Implement `NetworkTransport` (exported from `incanto/net`): `connect`/`disconnect`,
|
|
@@ -354,6 +398,22 @@ Kernel constraints (agent8 platform facts — don't fight them):
|
|
|
354
398
|
- room data is ephemeral — persist to `$global` before rooms empty
|
|
355
399
|
- never expose unthrottled per-frame remoteFunction (rapid calls are rejected); guard score/economy with `$lock`
|
|
356
400
|
|
|
401
|
+
## Changing scene in a multiplayer game
|
|
402
|
+
|
|
403
|
+
A manager is bound to the ROOM the scene it was created for declares.
|
|
404
|
+
|
|
405
|
+
- **Same room, new scene** (level 2 of one match, a map vote): nothing to do.
|
|
406
|
+
The manager rebinds to the incoming tree and keeps broadcasting; the new
|
|
407
|
+
owner node re-sends its full state.
|
|
408
|
+
- **Different room** (lobby → match, leaving for another arena): create a NEW
|
|
409
|
+
manager for the new room. Broadcasting stops on purpose — leaking one room's
|
|
410
|
+
state into another is exactly what the binding prevents — and the engine logs
|
|
411
|
+
an error saying which room it is bound to and which the scene asked for.
|
|
412
|
+
|
|
413
|
+
Dispose the old handle whenever you like, before or after creating the new one:
|
|
414
|
+
disposing a handle that a later `create()` already superseded leaves the live
|
|
415
|
+
manager alone.
|
|
416
|
+
|
|
357
417
|
## State rules
|
|
358
418
|
|
|
359
419
|
- JSON-safe by construction: vectors as arrays, no `undefined`/`NaN`/typed arrays
|
|
@@ -392,3 +452,23 @@ changed nothing — a ✓ that could not have been a ✗.
|
|
|
392
452
|
|
|
393
453
|
If your server cannot be loaded the run REFUSES rather than quietly measuring a
|
|
394
454
|
game without its own rules.
|
|
455
|
+
|
|
456
|
+
**Pass `--behaviors src/behaviors.ts` too.** A sync key is very often written by
|
|
457
|
+
game TypeScript (`player.firing = input.isPressed('fire')`), and without the
|
|
458
|
+
behaviors the harness cannot tell a DEAD sync key from one whose writer was
|
|
459
|
+
never loaded — so it says nothing about them at all. With the behaviors there,
|
|
460
|
+
a key the owner cannot read is reported as what it is:
|
|
461
|
+
|
|
462
|
+
```
|
|
463
|
+
✗ p1 declares `firing` in its `network.sync` and cannot READ it — it is never
|
|
464
|
+
sent, so no client will ever see it change.
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
That is the most complete failure of replication there is, and the rung used to
|
|
468
|
+
grade it PASS: an unreadable key was dropped from the comparison, so
|
|
469
|
+
every client agreed perfectly about a value nobody ever sent.
|
|
470
|
+
|
|
471
|
+
**`collection:` spawners are compared too.** The account-keyed half of the check
|
|
472
|
+
cannot see them — a bullet has no account — so a spawner that took a hundred
|
|
473
|
+
entities off the wire and materialised none of them used to be entirely silent.
|
|
474
|
+
Now it says which spawner, and how many entities it dropped.
|