incanto 0.69.0 → 0.70.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 +125 -44
- package/bin/incanto.mjs +3 -2
- package/dist/2d.d.ts +281 -34
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +157 -9
- package/dist/3d.js +7 -7
- package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
- package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-DaMxqfNE.d.ts} +2 -2
- package/dist/{behavior-rm8vVzu_.d.ts → behavior-DZExDn9o.d.ts} +744 -46
- package/dist/{create-game-CwxEDlCm.js → create-game-Bwvh6q8A.js} +142 -59
- package/dist/{create-game-CvJ1wSRP.js → create-game-C7ffQWW7.js} +97 -47
- 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 +1065 -863
- package/dist/env.d.ts +1 -1
- package/dist/env.js +5 -3
- package/dist/{environment-presets-BO-7XoFV.js → environment-presets-D6Q5BxeE.js} +298 -45
- package/dist/{gameplay-D1KixWK8.js → gameplay-BfHkuzVb.js} +809 -211
- package/dist/gameplay.d.ts +187 -7
- package/dist/gameplay.js +1 -1
- package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
- package/dist/index.d.ts +156 -13
- package/dist/index.js +10 -12
- package/dist/json-CfTjpvW8.js +67 -0
- package/dist/{loader-DadV2QCT.d.ts → loader-Cff09LMm.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-CE0Qvy3V.js} +136 -11
- package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CpH-2gn5.js} +104 -23
- package/dist/{teardown-CMqFVxO9.js → picking-CQJ_PJKh.js} +103 -12
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/{register-mmfugpaQ.js → register-6DYnKZcy.js} +546 -817
- package/dist/{register-DbVArsns.js → register-Bkk0wSDB.js} +332 -23
- package/dist/{replay-BEHLDcQV.js → replay-DjAkAzMq.js} +224 -14
- package/dist/{replay-BQfkX4x7.d.ts → replay-Dmw-PKQu.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-BXVg148r.js} +4570 -2322
- package/dist/{split-screen-Cg76MCV5.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
- package/dist/{split-screen-CauMwu6y.js → split-screen-PL78oVXP.js} +159 -26
- package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
- package/dist/{src-CYEu-_S9.js → src-Cxfiv1Hg.js} +2 -17
- package/dist/test-iHYVUcDK.js +4036 -0
- package/dist/test.d.ts +531 -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 +176 -6
- package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
- package/editor/assets/agent8-D0MS174y.js +1 -0
- package/editor/assets/{debug-CMULinBW.js → debug-BnXkKuYu.js} +2 -2
- package/editor/assets/index-CIu3uc3l.js +11046 -0
- package/editor/index.html +1 -1
- package/package.json +4 -14
- package/schemas/scene.schema.json +30 -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 +83 -6
- package/skills/incanto-building-2d-games.md +77 -8
- 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 +109 -19
- package/skills/incanto-hud.md +128 -7
- package/skills/incanto-localization.md +13 -5
- package/skills/incanto-multiplayer.md +83 -3
- package/skills/incanto-node-reference.md +222 -58
- package/skills/incanto-performance.md +52 -0
- package/skills/incanto-physics-and-input.md +123 -24
- package/skills/incanto-playtesting.md +78 -2
- package/skills/incanto-save-slots.md +161 -6
- package/skills/incanto-scene-json-authoring.md +69 -12
- package/skills/incanto-verifying-your-game.md +196 -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 +103 -378
- 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 +1255 -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 +128 -600
- 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 +41 -195
- 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 +78 -217
- 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 +21 -0
- package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
- package/templates-app/village-quest-3d/src/main.ts +11 -1
- package/templates-app/village-quest-3d/src/village.scene.json +199 -838
- 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
|
@@ -324,3 +324,55 @@ rendered a five-pass frame at 3.3 ms where `low` rendered one pass at 1.24 ms
|
|
|
324
324
|
A tier is also REVERSIBLE now: it is applied against the scene FILE rather than
|
|
325
325
|
against whatever the last tier left behind, so `low` → `high` restores exactly
|
|
326
326
|
what you authored instead of leaving it stripped.
|
|
327
|
+
|
|
328
|
+
## How big a game actually is, and the chunk named after the wrong thing
|
|
329
|
+
|
|
330
|
+
Measured on all six templates, scaffolded from the packed tarball and built:
|
|
331
|
+
|
|
332
|
+
```
|
|
333
|
+
eager on disk
|
|
334
|
+
molehill-2d 967 KB 5.8 MB
|
|
335
|
+
platformer-2d 981 KB 5.9 MB
|
|
336
|
+
star-survivor 962 KB 5.9 MB
|
|
337
|
+
beacon-isle-3d 1414 KB 5.8 MB
|
|
338
|
+
tps-3d 1376 KB 5.8 MB
|
|
339
|
+
village-quest-3d 1469 KB 5.8 MB
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**`dist/` is six megabytes and a player downloads one.** The rest is lazy: the
|
|
343
|
+
Rapier wasm builds (2D and 3D, ~4.5 MB together) load only when a scene has
|
|
344
|
+
physics bodies, and the editor chunk only if someone opens it. Read the
|
|
345
|
+
`<script>` and `<link rel=modulepreload>` tags in `dist/index.html` for what
|
|
346
|
+
actually loads — `du -sh dist` answers a question nobody asked.
|
|
347
|
+
|
|
348
|
+
**The vendor chunk is named after whichever module the bundler happened to pick,
|
|
349
|
+
and it is not the one you think.** In every one of those builds the ~790 KB
|
|
350
|
+
chunk is called `quiet-rapier-*.js` and contains **three.js** — no Rapier at
|
|
351
|
+
all. A size audit that reads names concludes the physics engine is eager in a
|
|
352
|
+
mouse game with no bodies in it. Read the contents:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
grep -c WebGLRenderer dist/assets/quiet-rapier-*.js # three.js lives here
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Name them yourself if it matters to you — it changes nothing about what loads,
|
|
359
|
+
only what the file is called:
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
export default defineConfig({
|
|
363
|
+
build: {
|
|
364
|
+
rollupOptions: {
|
|
365
|
+
output: {
|
|
366
|
+
manualChunks: (id) =>
|
|
367
|
+
id.includes('node_modules/three') ? 'three'
|
|
368
|
+
: id.includes('@dimforge/rapier') ? 'rapier'
|
|
369
|
+
: undefined,
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
three is eager in a 2D game too, and that is not a bug: `Renderer2D` draws
|
|
377
|
+
through the same WebGL renderer as the 3D one — shared-PlaneGeometry meshes and
|
|
378
|
+
an orthographic camera. It is the price of one renderer instead of two.
|
|
@@ -80,14 +80,22 @@ mesh or one model and warns when the body carries more.
|
|
|
80
80
|
- **`StaticBody2D/3D`** — immovable (ground, walls). Props: `collider`.
|
|
81
81
|
- **`RigidBody2D/3D`** — simulated. Props: `collider`, `mass 1`, `gravityScale 1`,
|
|
82
82
|
`fixedRotation false`, `friction 0.5`, `restitution 0`, `linearVelocity` (write to launch,
|
|
83
|
-
read back every step).
|
|
83
|
+
read back every step; `velocity` is an alias for it). 3D also has
|
|
84
|
+
`angularVelocity`. Every one of them is LIVE — write it mid-run and the solver
|
|
85
|
+
picks it up on the next step.
|
|
84
86
|
- **`Area2D/3D`** — sensor. Emits `triggerEnter(other)` / `triggerExit(other)`. Never blocks
|
|
85
87
|
movement. Solid bodies emit the same signals on real contact (one mental model).
|
|
86
88
|
Areas overlapping OTHER Areas fire too (e.g. a weapon-hitbox Area over an
|
|
87
89
|
enemy-hitbox Area) — neither side needs to be a Body.
|
|
88
90
|
- **`CharacterBody2D/3D`** — kinematic character (Rapier KCC). Props: `collider`
|
|
89
|
-
(capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45
|
|
90
|
-
|
|
91
|
+
(capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45`,
|
|
92
|
+
`stepHeight` (3D `0.35` m / 2D `35` px — see below).
|
|
93
|
+
API: `moveAndSlide()` (call from `fixedUpdate`), `isOnFloor()`,
|
|
94
|
+
**`isOnWall()`** and **`isOnCeiling()`**. Which wall is answered in the shape
|
|
95
|
+
each dimension has: 2D's `wallSide()` is `-1` left / `+1` right, 3D's
|
|
96
|
+
`wallNormal()` is the wall's normal pointing away from it — the direction a
|
|
97
|
+
wall jump pushes. Without them a wall jump or a wall slide is not awkward to
|
|
98
|
+
write, it is impossible: there is nothing to ask.
|
|
91
99
|
|
|
92
100
|
### `enabled` — a collider that is off for now
|
|
93
101
|
|
|
@@ -105,6 +113,13 @@ sword.enabled = true; // the active window of the swing
|
|
|
105
113
|
sword.enabled = false; // …and done
|
|
106
114
|
```
|
|
107
115
|
|
|
116
|
+
**A collider's keys are CLOSED**: `shape`, `size`, `radius`, `height`, `offset`
|
|
117
|
+
and (2D) `oneWay` — plus `vertices`/`indices` for a 3D trimesh. Anything else is
|
|
118
|
+
a load error naming the nearest one, because
|
|
119
|
+
`{"shape": "circle", "radius": 8, "offest": [0, -24]}` used to load clean with
|
|
120
|
+
its hitbox 24 px from where it was meant to be. `friction`, `restitution` and
|
|
121
|
+
mass are props of the BODY, not of its collider.
|
|
122
|
+
|
|
108
123
|
**Do not arm a hitbox by swapping `collider` in and out.** Replacing the
|
|
109
124
|
collider prop tears the rigid body down and builds a new one — twice per swing —
|
|
110
125
|
and a scene authored `"collider": {}` warns `has no collider — physics skips it`
|
|
@@ -169,9 +184,19 @@ Space/arrows; pass `'none'` to opt out). Keys typed into INPUT/TEXTAREA/SELECT/
|
|
|
169
184
|
contenteditable are ignored entirely, so DOM UI overlays keep working.
|
|
170
185
|
`engine.input.dispose()` detaches every attached source (keyboard and pointer).
|
|
171
186
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
187
|
+
**One press is ONE edge**, in `update` and in `fixedUpdate` alike — the fixed pass has
|
|
188
|
+
its own edge view, drained after its first step, so a dropped frame carrying five steps
|
|
189
|
+
delivers the edge once and a 144 Hz tick that runs no fixed step at all does not lose it.
|
|
190
|
+
Same for `justReleased`. (Until 0.63 this warned that the edge repeated in every fixed
|
|
191
|
+
step of a slow frame; it has not since the fixed pass got its own edge sets, and nothing
|
|
192
|
+
noticed for two releases.)
|
|
193
|
+
|
|
194
|
+
⚠️ What IS worth knowing: **a press injected from inside the frame lands on the NEXT
|
|
195
|
+
tick.** `pressAction`/`releaseAction` called from a `fixedUpdate`, an `update` or an
|
|
196
|
+
`engine.updated` handler — replay drivers, AI input, touch overlays — is invisible for
|
|
197
|
+
the rest of that tick and arrives at the next one, because an edge created after the
|
|
198
|
+
pass that reads it would be created and destroyed without anything seeing it. The HELD
|
|
199
|
+
state (`isPressed`) is immediate either way.
|
|
175
200
|
|
|
176
201
|
## Touch controls (mobile web)
|
|
177
202
|
|
|
@@ -307,11 +332,25 @@ body.velocity = [2, 0, 0]; // or set the velocity outright
|
|
|
307
332
|
|
|
308
333
|
`applyImpulse` is what the character controller itself runs on. It is an
|
|
309
334
|
IMPULSE, not a force: it changes velocity once, so a continuous push is one call
|
|
310
|
-
per frame from `update(dt)` scaled by `dt`.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
335
|
+
per frame from `update(dt)` scaled by `dt`. In 2D the impulse is in px·kg/s,
|
|
336
|
+
y-down, exactly like `linearVelocity`.
|
|
337
|
+
|
|
338
|
+
Readable/writable body state, in BOTH adapters: `velocity` (= `linearVelocity`),
|
|
339
|
+
`angularVelocity`, `mass`, `gravityScale`, `friction`, `restitution`. Read the
|
|
340
|
+
spin to see how fast a lever is swinging; write it to launch a spinning body.
|
|
341
|
+
3D's is a 3-vector about the world axes, 2D's is the scalar rad/s about z with
|
|
342
|
+
the same sign as `rotation` (clockwise, because 2D is y-down). There is no
|
|
343
|
+
torque call in either. Writes land on the next step — these are live props, not
|
|
344
|
+
load-time constants.
|
|
345
|
+
|
|
346
|
+
`fixedRotation` is authored, not live: it locks the body's rotation when the
|
|
347
|
+
body is CREATED. Changing it mid-run does nothing, and a locked body's
|
|
348
|
+
`angularVelocity` is zero rather than whatever was authored.
|
|
349
|
+
|
|
350
|
+
> Four of these were once false, silently: `applyImpulse` existed only in
|
|
351
|
+
> 3D (a TypeError in 2D), `body.velocity = […]` wrote a stray field nothing read
|
|
352
|
+
> in both, `mass`/`friction`/`restitution` were read once at creation in both,
|
|
353
|
+
> and `gravityScale` was live in 3D and dead in 2D.
|
|
315
354
|
|
|
316
355
|
## What is inside this Area right now?
|
|
317
356
|
|
|
@@ -354,7 +393,12 @@ question you usually mean.
|
|
|
354
393
|
only** — never their contact with the rest of the world. Defaulting a spring
|
|
355
394
|
off drops a barrel roped to the floor straight through it, which is how this
|
|
356
395
|
table was arrived at.
|
|
357
|
-
3.
|
|
396
|
+
3. **A joint's two ends must be two DIFFERENT bodies.** It is a child of one and
|
|
397
|
+
points at the other, so `"target": ".."` — which reads like naming the body
|
|
398
|
+
it belongs to — is now a load-time error: Rapier accepts a body jointed to
|
|
399
|
+
itself and constrains nothing, and the rope fell exactly as if it were not
|
|
400
|
+
there.
|
|
401
|
+
4. **`anchor`/`targetAnchor` are local offsets from each body's ORIGIN**,
|
|
358
402
|
resolved against the bodies' positions at load. If the two disagree the solver
|
|
359
403
|
snaps the body into place on the first frame, and a 4 cm typo is silent. Author
|
|
360
404
|
them with arithmetic (generate the scene) rather than by hand.
|
|
@@ -380,19 +424,41 @@ Both runtimes expose the same query (2D in PIXELS y-down, 3D in meters):
|
|
|
380
424
|
|
|
381
425
|
```ts
|
|
382
426
|
const hit = physics.castRay(origin, dir, maxLen, excludeBody?, { staticOnly?: true });
|
|
427
|
+
// hit: { distance, normal, node, point } — `point` is WHERE it landed, in the
|
|
428
|
+
// scene's own units. `dir` may be any length; the ray normalizes it, so
|
|
429
|
+
// `origin + dir * distance` by hand is wrong unless you normalize first.
|
|
383
430
|
// → { distance, normal, node } | null (sensors never block rays)
|
|
384
431
|
```
|
|
385
432
|
|
|
433
|
+
Both also answer two questions about a body that the node props cannot:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
physics.velocityOf(body); // px/s (2D, y-down) or m/s (3D) — from the SOLVER
|
|
437
|
+
physics.massOf(body); // what the solver settled on, collider-derived
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
`linearVelocity` on the node is written back once per step, AFTER the solve — so
|
|
441
|
+
a behavior reading it from inside `fixedUpdate`, which is where a collision
|
|
442
|
+
response belongs, sees the value from BEFORE the impact. That is exactly the
|
|
443
|
+
moment a game wants to know how hard it hit something. Scale a push by `massOf`
|
|
444
|
+
rather than by the authored `mass` prop, which the solver may not be using.
|
|
445
|
+
|
|
446
|
+
`dir` may be ANY length — `target - eye` is the usual spelling and is metres
|
|
447
|
+
long. It is normalized for you, so `distance` and `maxLen` are always plain
|
|
448
|
+
metres (2D: pixels), never multiples of the vector you passed. A zero-length
|
|
449
|
+
direction points nowhere and returns `null`.
|
|
450
|
+
|
|
386
451
|
Exclude the shooter's own body when casting from inside it — a ray that starts
|
|
387
452
|
inside its own collider hits itself at distance 0, which reads as "blocked" and
|
|
388
453
|
is the reason a vision cone can come back permanently blind.
|
|
389
454
|
|
|
390
|
-
|
|
391
|
-
(the camera boom, ledge feelers
|
|
455
|
+
Both also have a THICK ray — a sphere sweep in 3D, a circle sweep in 2D — for
|
|
456
|
+
probes where skimming matters (the camera boom, ledge feelers, a shot that must
|
|
457
|
+
not thread a one-pixel gap between two floor tiles):
|
|
392
458
|
|
|
393
459
|
```ts
|
|
394
460
|
const hit = physics.castSphere(origin, dir, radius, maxLen, excludeBody?, { staticOnly?: true });
|
|
395
|
-
// → { distance, node } | null (distance = travel of the
|
|
461
|
+
// → { distance, node } | null (distance = travel of the CENTRE)
|
|
396
462
|
```
|
|
397
463
|
|
|
398
464
|
## Gamepad
|
|
@@ -440,12 +506,15 @@ the tree says, without your scene falling over while you look at it. Use it for
|
|
|
440
506
|
any "show me the collision geometry" tool. Do NOT use it to freeze a running
|
|
441
507
|
game — that is `engine.timeScale = 0`, which keeps the world coherent.
|
|
442
508
|
|
|
443
|
-
## A walking body climbs a step (`stepHeight`, 3D)
|
|
509
|
+
## A walking body climbs a step (`stepHeight`, 2D **and** 3D)
|
|
444
510
|
|
|
445
|
-
`CharacterBody3D.stepHeight` (default `0.35` m
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
511
|
+
`CharacterBody3D.stepHeight` (default `0.35` m) and `CharacterBody2D.stepHeight`
|
|
512
|
+
(default `35` px — the same 0.35 m at the 2D world's scale of 100 px = 1 m,
|
|
513
|
+
which is why gravity defaults to 980) are how high a ledge the body walks UP
|
|
514
|
+
without jumping. `0` = off. It is Rapier's autostep, and **Rapier does not
|
|
515
|
+
autostep unless it is asked** — before 0.63 nothing asked in 3D, and for longer
|
|
516
|
+
still nothing asked in 2D at all, where a lip ONE PIXEL high stopped a walking body
|
|
517
|
+
dead.
|
|
449
518
|
|
|
450
519
|
You will not see this on the player: `CharacterController3D` rides a hover spring
|
|
451
520
|
rather than the character controller, so it floats over small ledges already.
|
|
@@ -522,6 +591,16 @@ game.pick(x, y); // the node under that pixel, or null
|
|
|
522
591
|
A stretched canvas is handled: `pointerPosition()` scales client → canvas pixels,
|
|
523
592
|
so the coordinates are the ones `pick()` wants.
|
|
524
593
|
|
|
594
|
+
**The pointer has to be attached, and now attaches itself.** `createGame2D`/
|
|
595
|
+
`createGame3D` turn it on when the scene contains a `Clickable` — the same
|
|
596
|
+
`'auto'` shape `physics` has always had — and they ask again on every scene
|
|
597
|
+
change, because a title screen you click into a level has no `Clickable` in it
|
|
598
|
+
(a `UiButton` is DOM) and the level is full of them. Pass `pointer: true` explicitly when a
|
|
599
|
+
behaviour of YOUR OWN reads `pointerPosition()`; the boot cannot see that. A
|
|
600
|
+
`Clickable` that finds no pointer (or no picker) for two seconds says so by
|
|
601
|
+
name instead of sitting there inert, which is what it used to do: no error, a
|
|
602
|
+
clean `incanto-check`, a clean audit, and a board that did nothing when clicked.
|
|
603
|
+
|
|
525
604
|
From scene JSON, the **`Clickable`** behavior needs no code at all:
|
|
526
605
|
|
|
527
606
|
```json
|
|
@@ -531,7 +610,7 @@ From scene JSON, the **`Clickable`** behavior needs no code at all:
|
|
|
531
610
|
| prop | default | |
|
|
532
611
|
| --- | --- | --- |
|
|
533
612
|
| `button` | `0` | 0 left · 1 middle · 2 right |
|
|
534
|
-
| `maxDistance` | `0` | ignore clicks further than this (0 = any) |
|
|
613
|
+
| `maxDistance` | `0` | ignore clicks further than this **from the current camera** (0 = any) |
|
|
535
614
|
| `enabled` | `true` | stop responding without detaching |
|
|
536
615
|
|
|
537
616
|
Signals: **`clicked(node)`**, **`hovered(node)`**, **`unhovered(node)`** — wire
|
|
@@ -550,11 +629,31 @@ ends elsewhere is not a click, the way every button on every platform behaves),
|
|
|
550
629
|
and a hit on a CHILD counts as a hit on the node — the raycast lands on the
|
|
551
630
|
visual mesh, which is usually a child.
|
|
552
631
|
|
|
632
|
+
**A finger that lifts stops hovering.** `hovered`/`unhovered` follow the cursor,
|
|
633
|
+
and a mouse cursor stays where you left it — but a finger ceases to exist, so a
|
|
634
|
+
touch release clears the pointer at the end of that frame (after the click has
|
|
635
|
+
fired, not with it). Without that the last-tapped node stayed `hovering: true`
|
|
636
|
+
for the rest of the session: a permanently highlighted tile, mole or card on
|
|
637
|
+
every phone. Do not build a hover-only affordance — on touch there is no hover
|
|
638
|
+
before the tap, only after it, and only for a frame.
|
|
639
|
+
|
|
640
|
+
**`visible: false` is not clickable.** A pixel the frame does not draw has
|
|
641
|
+
nothing under it, so hiding a node (or any ancestor of it) takes it out of the
|
|
642
|
+
pick — which is how you disable a button, a dialogue choice or a mole down its
|
|
643
|
+
hole without detaching anything. Before 0.71 three's raycaster reported hidden
|
|
644
|
+
geometry and `pick()` handed it straight to `Clickable`.
|
|
645
|
+
|
|
553
646
|
### Testing a mouse-driven game headlessly
|
|
554
647
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
648
|
+
**Use `runScript`'s `click` step** — `{ atMs: 100, click: 'Board/Tile3' }` — which
|
|
649
|
+
installs a geometric picker, points the cursor and spreads the press and release
|
|
650
|
+
across the two frames `Clickable` needs. `incanto-playtest` clicks too, and its
|
|
651
|
+
report says how many clicks it landed. See `incanto-verifying-your-game.md`.
|
|
652
|
+
|
|
653
|
+
The raw recipe below is what that does, and is still what you want when the
|
|
654
|
+
cursor has to be somewhere a node is not. There is no renderer and therefore no
|
|
655
|
+
raycast, so `engine.picker` is null and `Clickable` is inert. That is not a dead
|
|
656
|
+
end: give the engine a picker of your own and drive the cursor.
|
|
558
657
|
|
|
559
658
|
```ts
|
|
560
659
|
// A picker that answers from the tree instead of a GPU raycast.
|
|
@@ -51,12 +51,36 @@ two.
|
|
|
51
51
|
| `fell` | the player left the world past `--fall-below` (default: 50 m under the spawn in 3D, 1000 px under it in 2D) |
|
|
52
52
|
| `error` | `stats().errors` went above zero — a behavior threw |
|
|
53
53
|
| `stuck` | the clock ran out AND the player never got more than 3 m (96 px) from its spawn — it is wedged, or nothing moves it |
|
|
54
|
-
| `swapped` | the game changed scene. The bot binds player, destinations and the win/lose oracle ONCE, from the tree it started in, so nothing after a `goToScene` is measured — the run stops there and says so. It is not a defect, and the destinations it never got to are reported as unmeasured rather than missed. Judge a multi-scene game with a scripted `runScript`. |
|
|
54
|
+
| `swapped` | the game changed scene. The bot binds player, destinations and the win/lose oracle ONCE, from the tree it started in, so nothing after a `goToScene` is measured — the run stops there and says so. It is not a defect, and the destinations it never got to are reported as unmeasured rather than missed. Judge a multi-scene game with a scripted `runScript`. The bot's own KEYS are re-read on the swap: it used to hold the first scene's buttons into the second, and letting go of one the new scene never declared threw the whole playtest away. |
|
|
55
55
|
| `unfinished` | the clock ran out on a player that was getting around. Not a defect: a win that needs a SEQUENCE (talk to the NPC, then fetch, then return) is out of reach of a random walker, forever |
|
|
56
56
|
| `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) |
|
|
57
57
|
| `never fired` | signals a `connections[]` entry listens to that never happened |
|
|
58
|
+
| `declared and never read` | actions in the scene's `input{}` that NOTHING asked about, in any run — a key the control list promises and no code implements. Measured as a read, not a press, so the bot pressing it does not clear it. Two shipped starters had one: `tps-3d` declared `restart` (on `KeyR`, already the reload key) and `MISSION FAILED` was a dead end you left with F5. An action polled only on a screen no run reached lands here honestly — read it, do not gate on it |
|
|
58
59
|
| `danger` | how many times the player's `Health` emitted `damaged` — and when that is ZERO, WHICH zero it is: a scene with no hazard wired at all is a walkabout and worth shouting about; a scene full of them that never landed one is a report about the BOT, which a real player would outrun less easily. Hazards are counted from the scene FILE, because a `Spawner` detaches its prefab at ready and a wave game's hazards are not in the tree yet. |
|
|
59
60
|
|
|
61
|
+
**The bot presses the HUD.** Every focusable widget a player can currently see —
|
|
62
|
+
`UiButton`, `UiToggle`, `UiSelect`, `UiSlider` — gets pressed occasionally,
|
|
63
|
+
through the same entry point Enter and the pad's A use. That is the whole
|
|
64
|
+
interface of a menu, a shop, a settings screen or an idle game, and it was on
|
|
65
|
+
none of the lists the bot searched: one built from the tarball reported
|
|
66
|
+
`nothing here is drivable … nothing to point at` and `✗ stuck in 2/2` about a
|
|
67
|
+
game that plays perfectly. A widget under a hidden panel is not pressed, because
|
|
68
|
+
a player could not press it either. `report.runs[].presses` counts them.
|
|
69
|
+
|
|
70
|
+
**The bot drags between SLOTS.** Every `draggable` widget gets picked up now and
|
|
71
|
+
then and dropped on a `dropTarget` that is not its own parent, through
|
|
72
|
+
`dropOnto` — the same entry point a harness uses, with the same rules as the
|
|
73
|
+
pointer path. An inventory is the one screen the HUD skill says a click cannot
|
|
74
|
+
express, and it was the one screen the bot could not touch: a potion bench whose
|
|
75
|
+
every verb is a drag reported `nothing here is drivable` and `✗ stuck in 6/6`,
|
|
76
|
+
and now reports `324 slot drag(s)` and which of its wires never fired.
|
|
77
|
+
`report.runs[].widgetDrags` counts them.
|
|
78
|
+
|
|
79
|
+
**The line names every channel it used**, not the first one that fired: a game
|
|
80
|
+
played by dragging AND by keys reports both counts, because an either/or ladder
|
|
81
|
+
printed the drags and said nothing about three hundred key presses on the same
|
|
82
|
+
run.
|
|
83
|
+
|
|
60
84
|
**The bot can use the MOUSE.** If your scene has `Clickable` nodes, it presses
|
|
61
85
|
and releases over them at random — through the real `Clickable`, the real
|
|
62
86
|
`mouseJustPressed` edge and your real connection, with a picker that answers
|
|
@@ -70,6 +94,12 @@ A random clicker still cannot finish a game that needs a SEQUENCE — select a
|
|
|
70
94
|
trooper, then a tile, then end the turn — and that is reported as unmeasured,
|
|
71
95
|
not failed.
|
|
72
96
|
|
|
97
|
+
**A wire on a HUD widget is visible now too.** The whole screen-space subtree was
|
|
98
|
+
skipped when the run collected signals — right for "never reached", which is
|
|
99
|
+
about walking, and it took every `pressed`, `changed` and `dropped` wire with
|
|
100
|
+
it, so the line that exists to say "this wire does nothing" could never say it
|
|
101
|
+
about a HUD.
|
|
102
|
+
|
|
73
103
|
**Nothing here is a guess about your design.** The scene already declares what
|
|
74
104
|
winning is; this only listens.
|
|
75
105
|
|
|
@@ -102,10 +132,38 @@ geometry says you cannot. Check the jump height, the gap, the collider.
|
|
|
102
132
|
**`never fired`** means you drew a wire that does nothing. Either the signal is
|
|
103
133
|
unreachable or the connection is wrong.
|
|
104
134
|
|
|
135
|
+
**`silent`** names a declared emitter — an `AudioPlayer`, a particle node — that
|
|
136
|
+
never went off in any run. Emitters on a SPAWNER's prefab count: the template is
|
|
137
|
+
lifted out of the tree at load, so the check reads the scene FILE as well as the
|
|
138
|
+
loaded tree, and a wave of forty enemies is reported as its one authored
|
|
139
|
+
emitter, not forty.
|
|
140
|
+
|
|
105
141
|
**`nothing here can hurt you`** is a design report, not an error. Sometimes it
|
|
106
142
|
is exactly right (a walking simulator). Usually it means the hazards are not
|
|
107
143
|
wired up.
|
|
108
144
|
|
|
145
|
+
**`!` lines** are what the ENGINE warned about during the runs — a camera whose
|
|
146
|
+
`follow` resolves to a node with no position, a `DayNight` with no sky, a body
|
|
147
|
+
with no collider. They are not failures and they do not change the exit code;
|
|
148
|
+
they are the things that leave a game running and wrong, and they are usually
|
|
149
|
+
the first thing to read. One line per distinct warning, however many times it
|
|
150
|
+
fired.
|
|
151
|
+
|
|
152
|
+
**`error`** prints the throw under it — the behavior, the node and the
|
|
153
|
+
exception, in the engine's own words:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
✗ error in 8/8
|
|
157
|
+
behavior 'Boom' on /Game/Player/Sword (Node2D) threw in update — THE
|
|
158
|
+
SCRIPT is now skipped … Error: the sword script is broken
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A script that throws in `update` is quarantined (the node keeps running, its
|
|
162
|
+
script does not), so everything downstream of it simply stops happening and
|
|
163
|
+
every other line of the report describes the silence rather than the cause.
|
|
164
|
+
Read this one first. `incanto-verify` carries the same lines under its `plays`
|
|
165
|
+
rung.
|
|
166
|
+
|
|
109
167
|
**`stuck` in every run** means the player is not going anywhere: wedged in
|
|
110
168
|
geometry, spawned inside a collider, or missing the input map that moves it.
|
|
111
169
|
That is a real bug and worth chasing.
|
|
@@ -156,6 +214,18 @@ real thing:
|
|
|
156
214
|
bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts
|
|
157
215
|
```
|
|
158
216
|
|
|
217
|
+
**More than one file.** Behaviours split as a game grows — bosses in `boss.ts`,
|
|
218
|
+
the scene table in `scenes.ts` — so comma-separate them or repeat the flag,
|
|
219
|
+
rather than writing a barrel that re-exports everything for the CLI's benefit:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts,src/boss.ts
|
|
223
|
+
bunx incanto-verify src/game.scene.json --behaviors src/behaviors.ts --behaviors src/boss.ts
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Every bin that takes `--behaviors` accepts both forms: `check`, `playtest`,
|
|
227
|
+
`verify`, `feel`, `play`, `multiplay`.
|
|
228
|
+
|
|
159
229
|
## From TypeScript
|
|
160
230
|
|
|
161
231
|
```ts
|
|
@@ -166,7 +236,13 @@ console.log(playtestText(report));
|
|
|
166
236
|
```
|
|
167
237
|
|
|
168
238
|
`report.runs` carries per-run outcome, timing, reached paths, fired signals,
|
|
169
|
-
damage and the replay.
|
|
239
|
+
damage and the replay. `report.inertActions` is the dead-control list above.
|
|
240
|
+
|
|
241
|
+
The same question, without a playtest — after any run, scripted or hand-driven:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
game.engine.input.unreadActions(); // ['restart'] — declared, nothing read it
|
|
245
|
+
```
|
|
170
246
|
|
|
171
247
|
## Pairs with the feel report
|
|
172
248
|
|
|
@@ -42,10 +42,17 @@ Reloading from source sidesteps all three. The structure comes from the file
|
|
|
42
42
|
(authoritative, already validated); the save carries only what the file cannot
|
|
43
43
|
know.
|
|
44
44
|
|
|
45
|
-
**The cost, stated plainly: spawned enemies
|
|
46
|
-
restored.** You resume at the scene's start
|
|
47
|
-
quest flags intact — a checkpoint save. If
|
|
48
|
-
`serialize()` returns anything.
|
|
45
|
+
**The cost, stated plainly: spawned enemies, mid-level positions and anything
|
|
46
|
+
else that lives in the TREE are not restored.** You resume at the scene's start
|
|
47
|
+
with stats, inventory, unlocks and quest flags intact — a checkpoint save. If
|
|
48
|
+
your game needs a position, save it: `serialize()` returns anything.
|
|
49
|
+
|
|
50
|
+
"Inventory" there means a behavior's DATA — a list of item ids, a wallet, a
|
|
51
|
+
quest flag. An inventory made of WIDGETS MOVED BETWEEN SLOTS
|
|
52
|
+
(`incanto-hud.md`'s drag recipe) is structure, so it reloads exactly as the
|
|
53
|
+
file has it: measured on a bench with one item dragged across, the save
|
|
54
|
+
mentions no slot and the reload puts the item back on its shelf. That skill
|
|
55
|
+
prints the `serialize` that keeps the slot map as data.
|
|
49
56
|
|
|
50
57
|
**What the run CONSUMED is remembered.** Reloading from the file brings back
|
|
51
58
|
every gem you already picked up, which would let a collect-five-to-win run
|
|
@@ -83,11 +90,59 @@ class QuestLog extends Behavior {
|
|
|
83
90
|
`maxHealth` no — that comes back from the scene JSON, and duplicating it makes
|
|
84
91
|
old saves fight your balance patches.
|
|
85
92
|
|
|
93
|
+
**Randomness is carried for you.** `captureState()` records where
|
|
94
|
+
`engine.rng` has got to and `restoreState()` puts it back, because without that
|
|
95
|
+
a load replays the seeded sequence from the top — the first "random" event after
|
|
96
|
+
a save is the one the RUN opened with, and every loot roll, wander and spawn
|
|
97
|
+
wobble repeats the opening of the game. Nothing to do; `engine.rng.position` is
|
|
98
|
+
readable if you want it for something else.
|
|
99
|
+
|
|
86
100
|
`deserialize` is defensive on purpose: that data may come from a build of your
|
|
87
101
|
game that shipped six weeks ago. Check what you read.
|
|
88
102
|
|
|
89
|
-
Built-ins that already save: `
|
|
90
|
-
|
|
103
|
+
Built-ins that already save: `Collector` (total), `Currency` (amount),
|
|
104
|
+
`DayNight` (hour, paused), `Health` (current, dead), `SavePoint` (playtime),
|
|
105
|
+
`ScoreKeeper` (score, lives, won/lost), `WaveSpawner` (which wave).
|
|
106
|
+
|
|
107
|
+
`WaveSpawner` RESTARTS the wave you were on rather than resuming mid-spawn —
|
|
108
|
+
spawned entities are never restored, so resuming "four enemies into wave 3"
|
|
109
|
+
would resume a wave whose enemies do not exist. `waveStarted` fires again, so a
|
|
110
|
+
HUD wired to it catches up on its own.
|
|
111
|
+
|
|
112
|
+
Every one of them needs a `uid` on its node, and `incanto-check` says so if it
|
|
113
|
+
has none — that check reads the same list.
|
|
114
|
+
|
|
115
|
+
## Tell the screen: `announce()`
|
|
116
|
+
|
|
117
|
+
`deserialize` writes fields. Your HUD is wired to **signals** — that is what
|
|
118
|
+
`incanto-hud.md` teaches and the only thing the editor can wire — so a restore
|
|
119
|
+
that only writes fields leaves the screen showing a fresh start:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
HUD score="0" hp=100/100 gems="0"
|
|
123
|
+
TRUTH score=1400 hp=38 gems=7
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
with no error anywhere and a restore report of `{missing: [], restored: 3}`.
|
|
127
|
+
|
|
128
|
+
So there is a third hook, called once after the whole restore pass:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
override announce() {
|
|
132
|
+
this.emit('questChanged', this.stage); // what the screen SHOWS
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Emit only what displays.** Not `died`, not `won`, not `levelUp` — a save is
|
|
137
|
+
being read, nothing just happened, and re-firing an outcome signal on load is
|
|
138
|
+
how a Continue lands straight on the game-over screen it was loaded to escape.
|
|
139
|
+
It runs after every `deserialize` in the pass, so a handler that reads a sibling
|
|
140
|
+
sees restored values there too.
|
|
141
|
+
|
|
142
|
+
`Health`, `ScoreKeeper`, `Collector` and `Currency` implement it. `ScoreKeeper`
|
|
143
|
+
gained **`livesChanged`** for this: `lifeLost` is the EVENT (flash, sound,
|
|
144
|
+
respawn) and fires only on a real loss, while `livesChanged` is the COUNT and
|
|
145
|
+
fires on both. Wire a lives counter to `livesChanged`.
|
|
91
146
|
|
|
92
147
|
## Every node you save — and every node that VANISHES — needs a uid
|
|
93
148
|
|
|
@@ -109,6 +164,20 @@ it just wrote is already wrong. It does not guess a replacement: a uid survives
|
|
|
109
164
|
a rename and a reparent, and a saved PATH would point at whatever node moved
|
|
110
165
|
into that slot after your next edit.
|
|
111
166
|
|
|
167
|
+
**Put the uid on the node that OWNS the state, not on its art.** A collectible
|
|
168
|
+
is normally one node with the script and a plain child for the sprite:
|
|
169
|
+
|
|
170
|
+
```jsonc
|
|
171
|
+
{ "name": "Sword", "type": "Area2D", "uid": "n_sr9scvhygy01mgxr",
|
|
172
|
+
"script": { "name": "ItemPickup" },
|
|
173
|
+
"children": [ { "name": "Icon", "type": "Sprite2D" } ] } // no uid needed
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Freeing the sword takes the icon with it, so the parent's uid in `#freed`
|
|
177
|
+
records both — the audit says nothing about the child, and adding a uid to it
|
|
178
|
+
changes nothing. What the audit still catches is the child that goes while its
|
|
179
|
+
parent LIVES: that one really does come back.
|
|
180
|
+
|
|
112
181
|
|
|
113
182
|
|
|
114
183
|
The uid is the join key, because it is the one identifier that survives a rename
|
|
@@ -156,6 +225,35 @@ if (slot) {
|
|
|
156
225
|
`onReady` is where a behavior sets its starting values, so restoring first would
|
|
157
226
|
be overwritten.
|
|
158
227
|
|
|
228
|
+
**Not from `onReady` itself, either**, which is the tempting place to put
|
|
229
|
+
"continue my run" and the one that cannot work: the ready pass runs while the
|
|
230
|
+
scene is still loading, and `engine.scene` — which both halves of this API read
|
|
231
|
+
— is not assigned until `setScene`. Both say so now rather than answering
|
|
232
|
+
quietly:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
[incanto] restoreState() ran before the engine had a scene, and restored NOTHING.
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Before that it reported every uid in the save as `missing`, which is the
|
|
239
|
+
signature of a save format change, so the hunt started in the wrong place
|
|
240
|
+
entirely.
|
|
241
|
+
|
|
242
|
+
**To keep the continue INSIDE the game** (where a harness can reach it —
|
|
243
|
+
`runScript` boots the scene, never your `main.ts`), do it on the first frame:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
class Game extends Behavior {
|
|
247
|
+
private continued = false;
|
|
248
|
+
override update(): void {
|
|
249
|
+
if (this.continued) return;
|
|
250
|
+
this.continued = true;
|
|
251
|
+
const slot = this.slots.read('1');
|
|
252
|
+
if (slot) this.engine.restoreState(slot.state);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
159
257
|
It never throws. A save naming a uid this build deleted reports it in
|
|
160
258
|
`report.missing` and restores everything else; refusing to load would mean a
|
|
161
259
|
patch that moves one node deletes everyone's progress.
|
|
@@ -235,6 +333,63 @@ slots.clear(); // "delete all data"
|
|
|
235
333
|
One `SavePoint` per slot is the declarative version: three nodes with
|
|
236
334
|
`slot: "1" | "2" | "3"`, each probing into its own row of the menu.
|
|
237
335
|
|
|
336
|
+
### Restore into a scene that has been PLAYED
|
|
337
|
+
|
|
338
|
+
A save is restored INTO a freshly loaded scene. The structure comes from the
|
|
339
|
+
file; `#freed` then takes away the nodes that run had consumed — and nothing
|
|
340
|
+
puts any back. So a pause-menu Load, a slot menu, or death wired straight to
|
|
341
|
+
`SavePoint.restore()` leaves THIS run's collectibles deleted:
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
CONTROL restart the scene from source, then restore → gems [Gem2,Gem3], won
|
|
345
|
+
death wired straight to restore (no reload) → gems [Gem3], lost
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
…and the next autosave writes that hybrid back to the slot, so the unwinnable
|
|
349
|
+
run survives a page reload. Reload first — `GameFlow.restart` with
|
|
350
|
+
`restoreOnReady`, or `restartScene(engine)` and then restore. After a restart
|
|
351
|
+
the SavePoint node is a NEW one, so a held behavior reference is stale.
|
|
352
|
+
|
|
353
|
+
`report.stale` names the authored nodes this tree consumed that the save does
|
|
354
|
+
not account for, and the engine warns when it is non-empty. It is a warning
|
|
355
|
+
rather than an error because a node that freed ITSELF on a timer lands in the
|
|
356
|
+
same set, and only your game knows which of its nodes are transient.
|
|
357
|
+
|
|
358
|
+
### A save hook that throws
|
|
359
|
+
|
|
360
|
+
`serialize()`, `deserialize()` and `announce()` are your code, and your code
|
|
361
|
+
throws. All three are caught now, and each says something different:
|
|
362
|
+
|
|
363
|
+
- a **`serialize()`** that throws leaves a HOLE in the save — that behavior's
|
|
364
|
+
whole run is missing — so `SavePoint.save()` **refuses to write** and emits
|
|
365
|
+
`saveFailed` instead of `saved`. Overwriting the previous slot with a holed
|
|
366
|
+
save destroys the progress the player actually had. `engine.lastCaptureFailures`
|
|
367
|
+
names them if you call `captureState()` yourself.
|
|
368
|
+
- a **`deserialize()`** that throws is in `report.skipped` as before, and now
|
|
369
|
+
also in **`report.refused`** with the reason — a save this build cannot read
|
|
370
|
+
and a node with no `deserialize` at all are different problems.
|
|
371
|
+
- an **`announce()`** that throws means the state IS restored and the SCREEN was
|
|
372
|
+
not told: **`report.unannounced`**. It reported `restored: 2, expected: 2`
|
|
373
|
+
over a HUD showing zeros, and it does not take an override to reach — a
|
|
374
|
+
`getNode('HUD/ScoreLabel')` on a renamed node lands there.
|
|
375
|
+
|
|
376
|
+
### A save that cannot be read
|
|
377
|
+
|
|
378
|
+
A truncated write (a power cut, a tab closed mid-save) or a slot from a build
|
|
379
|
+
that predates this one is not offered by `all()` — but it is not *gone* either,
|
|
380
|
+
and a load menu that quietly shows one fewer row than the player remembers is
|
|
381
|
+
the worst thing a save system can do:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
for (const { id, why } of slots.problems()) {
|
|
385
|
+
render(`slot ${id}: ${why === 'corrupt' ? 'damaged' : 'from an older version'}`);
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The slot INDEX is rebuilt from storage when it cannot be read, so one corrupt
|
|
390
|
+
byte in a derived list no longer hides every save on the machine — nor lets the
|
|
391
|
+
next `write()` orphan them. It says so on the console when it does.
|
|
392
|
+
|
|
238
393
|
## Checking your coverage
|
|
239
394
|
|
|
240
395
|
```ts
|