incanto 0.74.0 → 0.76.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/incanto-multiplay.mjs +52 -4
- package/dist/2d.d.ts +83 -7
- package/dist/2d.js +4 -3
- package/dist/3d.d.ts +861 -167
- package/dist/3d.js +6 -6
- package/dist/{agent8-CHTT4unP.js → agent8-B-bh3n0X.js} +1 -1
- package/dist/{audio-player-BOrk4eQx.d.ts → audio-player-BRo2uvG6.d.ts} +23 -2
- package/dist/{behavior-J1cmVah0.d.ts → behavior-B_245qRy.d.ts} +15 -1
- package/dist/{create-game-ssvGvP7W.js → create-game-BMIPKrQW.js} +84 -8
- package/dist/{create-game-BAiA-FjP.js → create-game-Dai5vfVu.js} +80 -11
- package/dist/debug.d.ts +1 -1
- package/dist/{editor-switch-CFU9mCec.d.ts → editor-switch-Bzt0GzVp.d.ts} +13 -3
- package/dist/editor.js +1600 -1529
- package/dist/env.d.ts +1 -1
- package/dist/{environment-presets-BkqlWewf.js → environment-presets-e_9YCnGu.js} +2361 -104
- package/dist/{gameplay-Bxe1sMVT.js → gameplay-Bfff_beb.js} +10919 -6798
- package/dist/gameplay.d.ts +573 -31
- package/dist/gameplay.js +3 -2
- package/dist/index.d.ts +150 -6
- package/dist/index.js +6 -6
- package/dist/{loader-BAeWMYfF.d.ts → loader-CbkVdXL8.d.ts} +2 -2
- package/dist/net.d.ts +2 -2
- package/dist/net.js +2 -2
- package/dist/{physics-2d-C-SfvjzH.js → physics-2d-Tuf5LAyq.js} +250 -31
- package/dist/{physics-3d-rOzeg890.js → physics-3d-C5MOujfQ.js} +673 -51
- package/dist/{picking-BMV34Pjl.js → picking-DIwE48sU.js} +10 -7
- package/dist/{save-slots-CEUGeLuB.js → pose-ByFB_J3O.js} +217 -11
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/{register-DRQyZKGU.js → register-7E3P1gz6.js} +191 -430
- package/dist/{register-CjociOtt.js → register-B768Qedj.js} +596 -2
- package/dist/{replay-B6BbgGM_.js → replay-DrqQ6dZj.js} +133 -18
- package/dist/{replay-wZK5Ok5c.d.ts → replay-W5nCw_cU.d.ts} +21 -2
- package/dist/{rng-Bb-IutXB.d.ts → rng-BsXZg3D6.d.ts} +7 -0
- package/dist/{split-screen-BxZ5rBe7.js → split-screen-B-dIcQsQ.js} +10 -5
- package/dist/{split-screen-D6sx8z3Z.d.ts → split-screen-CiLJf2-C.d.ts} +3 -3
- package/dist/{sprite-animation-Bz3DHD_4.js → sprite-animation-SQa5gIu2.js} +8 -6
- package/dist/{src-CqtvYtSN.js → src-D823V07k.js} +1 -1
- package/dist/{test-CEl0y2Sw.js → test-CVbxnXlv.js} +487 -82
- package/dist/test.d.ts +90 -12
- package/dist/test.js +2 -2
- package/dist/vite.js +34 -5
- package/editor/assets/{agent8-BlzturGi.js → agent8-DA-I5qhv.js} +1 -1
- package/editor/assets/{debug-BMUi8usj.js → debug-99tEPoGm.js} +1 -1
- package/editor/assets/index-Db74LZY-.js +11579 -0
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +1639 -280
- package/skills/incanto-3d-character.md +383 -20
- package/skills/incanto-audio.md +24 -2
- package/skills/incanto-building-2d-games.md +21 -2
- package/skills/incanto-building-3d-games.md +38 -3
- package/skills/incanto-editor.md +29 -8
- package/skills/incanto-environment.md +257 -14
- package/skills/incanto-game-feel.md +60 -1
- package/skills/incanto-gameplay-behaviors.md +668 -20
- package/skills/incanto-hud.md +116 -0
- package/skills/incanto-multiplayer.md +141 -2
- package/skills/incanto-node-reference.md +398 -16
- package/skills/incanto-performance.md +32 -0
- package/skills/incanto-physics-and-input.md +241 -13
- package/skills/incanto-playtesting.md +2 -2
- package/skills/incanto-save-slots.md +61 -2
- package/skills/incanto-scene-json-authoring.md +13 -0
- package/skills/incanto-verifying-your-game.md +86 -3
- package/templates-app/beacon-isle-3d/PROJECT/Status.md +12 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/game.scene.json +157 -3
- package/templates-app/molehill-2d/PROJECT/Status.md +7 -0
- package/templates-app/molehill-2d/package.json +1 -1
- package/templates-app/molehill-2d/src/game.scene.json +129 -2
- package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
- package/templates-app/platformer-2d/docs/project-2d-rules.md +3 -2
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/platformer-2d/src/behaviors.ts +40 -26
- package/templates-app/platformer-2d/src/game.scene.json +144 -2
- package/templates-app/platformer-2d/verify.ts +18 -1
- package/templates-app/star-survivor/PROJECT/Status.md +7 -0
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/star-survivor/src/behaviors.ts +9 -8
- package/templates-app/star-survivor/src/game.scene.json +142 -4
- package/templates-app/star-survivor/verify.ts +13 -0
- package/templates-app/tps-3d/PROJECT/Status.md +6 -0
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/game.scene.json +71 -10
- package/templates-app/village-quest-3d/PROJECT/Status.md +7 -0
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/behaviors.ts +8 -16
- package/templates-app/village-quest-3d/src/grove.scene.json +121 -5
- package/templates-app/village-quest-3d/src/village.scene.json +126 -2
- package/templates-app/village-quest-3d/verify.ts +52 -2
- package/editor/assets/index-CrUCQoaB.js +0 -11046
|
@@ -48,6 +48,38 @@ and are comparable to each other. Headless (`step()`), `fps`/`frameMs` are 0 and
|
|
|
48
48
|
the slices are still real CPU times — a behavior that got slower shows up in a
|
|
49
49
|
test.
|
|
50
50
|
|
|
51
|
+
### Measuring a frame by hand: `step()` ALREADY renders
|
|
52
|
+
|
|
53
|
+
The renderer subscribes to `engine.updated`, so a frame is one call:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const t0 = performance.now();
|
|
57
|
+
for (let i = 0; i < 120; i++) game.engine.step(1 / 60); // draws, too
|
|
58
|
+
const frameMs = (performance.now() - t0) / 120;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Calling `game.renderer.render(...)` after `step()` draws the scene TWICE and
|
|
62
|
+
doubles every number you take. It reads as a plausible result rather than an
|
|
63
|
+
obvious mistake — the flagship template measured 24.9 ms a frame that way and
|
|
64
|
+
**8.96 ms** the honest way, which is the difference between "this does not hold
|
|
65
|
+
60" and "this holds 60 twice over".
|
|
66
|
+
|
|
67
|
+
The same trap is why a hidden tab needs care: a tool-created tab has its
|
|
68
|
+
animation frames paused, so driving `step()` yourself is the only way to get
|
|
69
|
+
frames — and the one call is the whole frame.
|
|
70
|
+
|
|
71
|
+
For reference, the numbers `bunx incanto-new` scaffolds (Beacon Isle, an M-class
|
|
72
|
+
laptop, 220 nodes and 29 `Tree3D` groves):
|
|
73
|
+
|
|
74
|
+
| tier | frame | render | draw calls | triangles |
|
|
75
|
+
| --- | --- | --- | --- | --- |
|
|
76
|
+
| high | 8.96 ms | 8.44 ms | 999 | 7.3 M |
|
|
77
|
+
| medium | 7.75 ms | 7.04 ms | 580 | 6.2 M |
|
|
78
|
+
| low | 7.17 ms | 6.68 ms | 576 | 6.2 M |
|
|
79
|
+
|
|
80
|
+
The tier is doing its job — it takes 42% of the draw calls out — and the render
|
|
81
|
+
is where a 3D game's frame goes, which is what the dials in this skill are for.
|
|
82
|
+
|
|
51
83
|
## The short version
|
|
52
84
|
|
|
53
85
|
```ts
|
|
@@ -120,7 +120,13 @@ mesh or one model and warns when the body carries more.
|
|
|
120
120
|
enemy-hitbox Area) — neither side needs to be a Body.
|
|
121
121
|
- **`CharacterBody2D/3D`** — kinematic character (Rapier KCC). Props: `collider`
|
|
122
122
|
(capsule recommended), `velocity`, `stickToGround true`, `slopeLimitDeg 45`,
|
|
123
|
-
`stepHeight` (3D `0.35` m / 2D `35` px — see below),
|
|
123
|
+
`stepHeight` (3D `0.35` m / 2D `35` px — see below), **`pushes 0`** — the
|
|
124
|
+
mass (kg) this body SHOVES dynamic bodies with. The KCC treats a dynamic
|
|
125
|
+
body as an OBSTACLE — a character walks around things — so a beam on a
|
|
126
|
+
`Patrol` slid up to the player and stopped dead against them, forever. A
|
|
127
|
+
piston, a sweeping arm, a moving wall or a bruiser enemy is a thing that
|
|
128
|
+
shoves: `pushes: 90` and it pushes what it meets, the harder the heavier
|
|
129
|
+
(`examples/tower-3d`'s beam). `0` keeps every scene as it was. And
|
|
124
130
|
**`collideWithCharacters true`** — whether OTHER characters are obstacles to
|
|
125
131
|
this one. Leave it on for a few enemies; turn it OFF for a horde: eighty
|
|
126
132
|
chasers around a player cost 64–286 ms a STEP with it on (each slide
|
|
@@ -150,6 +156,22 @@ sword.enabled = true; // the active window of the swing
|
|
|
150
156
|
sword.enabled = false; // …and done
|
|
151
157
|
```
|
|
152
158
|
|
|
159
|
+
A switched-off SENSOR overlaps nothing — including a body teleported into it
|
|
160
|
+
while it is off. Rapier still reports an intersection-start for a disabled
|
|
161
|
+
collider in that case (measured: `isEnabled()` false, event delivered), and a
|
|
162
|
+
guard's grab disarmed for a cutscene killed the player the camera had left
|
|
163
|
+
standing in it; both adapters now drop any event on a node whose `enabled` is
|
|
164
|
+
false, and re-arming reports what the sensor is inside of.
|
|
165
|
+
|
|
166
|
+
**A child body sits where its parent's frame puts it — rotation included.** A
|
|
167
|
+
hit sensor two metres down +z of a hull turned 90° is at world +x, where the
|
|
168
|
+
renderer draws it. Both adapters used to sum positions up the tree and ignore
|
|
169
|
+
every rotation, so a guard's grab box "in front" of a turned guard, a tank's
|
|
170
|
+
barrel-tip sensor and a turret's muzzle all sat where nothing was drawn; a
|
|
171
|
+
dynamic body under a turned parent keeps a LOCAL pose on the way back. The same
|
|
172
|
+
composition is behind `worldPosition()` for behaviours, so `Turret`, `Sight`
|
|
173
|
+
and `FollowCamera` measure from where a node IS. Scale is still not composed.
|
|
174
|
+
|
|
153
175
|
**A collider's keys are CLOSED**: `shape`, `size`, `radius`, `height`, `offset`
|
|
154
176
|
and (2D) `oneWay` — plus `vertices`/`indices` for a 3D trimesh. Anything else is
|
|
155
177
|
a load error naming the nearest one, because
|
|
@@ -271,6 +293,21 @@ engine now does for you:
|
|
|
271
293
|
|
|
272
294
|
Every `incanto-new` template and every runnable example ships exactly this.
|
|
273
295
|
|
|
296
|
+
**A 3D game needs two more things a 2D one does not**, and both are one line:
|
|
297
|
+
|
|
298
|
+
- **The camera has to turn.** The stick walks; looking around is a DRAG on the
|
|
299
|
+
play area, and that only exists when the game asks for it:
|
|
300
|
+
`createGame3D({ pointer: true })`. Without it a phone player can walk and
|
|
301
|
+
never look. (A drag on the stick or a button never turns the camera — the
|
|
302
|
+
controls are siblings of the canvas and eat their own gestures.)
|
|
303
|
+
- **Portrait is a keyhole.** `Camera3D.fov` is the VERTICAL field of view, so a
|
|
304
|
+
`fov: 60` camera shows 91° across a 16:9 window and **30° across a 390×844
|
|
305
|
+
phone** — the same game, down a drinking straw. Set
|
|
306
|
+
`Camera3D.minHorizontalFov` (52 is a good floor) and the vertical fov widens
|
|
307
|
+
until that much is on screen; a window already wider keeps the fov it was
|
|
308
|
+
authored with. Pair it with `CharacterController3D.pitchStart` (about −14°) —
|
|
309
|
+
a tall screen spends its top half on sky with a level camera.
|
|
310
|
+
|
|
274
311
|
Two more phone facts worth knowing:
|
|
275
312
|
|
|
276
313
|
- **Audio unlocks on ANY first gesture** — the canvas, a key, or the on-screen
|
|
@@ -349,11 +386,146 @@ body B (any node path — `%Name` is typical):
|
|
|
349
386
|
]}
|
|
350
387
|
```
|
|
351
388
|
|
|
352
|
-
Types — 2D: `fixed` (weld) · `revolute` (pin/hinge) · `
|
|
353
|
-
· `spring` (pull toward `length` with
|
|
354
|
-
`
|
|
355
|
-
|
|
356
|
-
|
|
389
|
+
Types — 2D: `fixed` (weld) · `revolute` (pin/hinge) · `prismatic` (a SLIDE
|
|
390
|
+
along `axis`) · `rope` (max distance) · `spring` (pull toward `length` with
|
|
391
|
+
`stiffness`/`damping`). 3D has all of those plus `spherical` (ball joint);
|
|
392
|
+
its `revolute` turns about `axis` and its `prismatic` slides along it (local
|
|
393
|
+
to the joint's body, default `[0, 1, 0]`; 2D's `axis` defaults to `[0, 1]`,
|
|
394
|
+
so an "up" slide in y-down 2D is `[0, -1]`). `length: 0` measures the body
|
|
395
|
+
distance at creation — and a rope's or spring's `length`, `stiffness` and
|
|
396
|
+
`damping` are LIVE: a write REMAKES the joint on the next step, the bodies
|
|
397
|
+
keeping their motion, which is how a grapple reels its rope in
|
|
398
|
+
(`rope.length -= 5 * dt` — `examples/grapple-3d`). `anchor`/`targetAnchor`
|
|
399
|
+
are local offsets (px / m).
|
|
400
|
+
Chains work (pendulums, bridges: each link a body + joint to the previous —
|
|
401
|
+
`examples/wreck-3d` hangs a wrecking ball off six of them).
|
|
402
|
+
|
|
403
|
+
**A hinge has stops and a motor; so does a slide.** On a `revolute` or a
|
|
404
|
+
`prismatic` in either dimension:
|
|
405
|
+
|
|
406
|
+
| prop | default | what it is |
|
|
407
|
+
|---|---|---|
|
|
408
|
+
| `limits` | `[]` | the stops — `[minDeg, maxDeg]` for a hinge, `[min, max]` metres (px) for a slide; empty = free. A door that opens one way: `[0, 110]`; a portcullis that lifts three metres: `[0, 3]` |
|
|
409
|
+
| `motorSpeed` | `0` | the speed the motor drives it at — degrees a second (hinge) or metres a second (slide; px in 2D); `0` = no motor, the joint is free. A mill wheel: `40`; a drawbridge coming down: `-25` toward its lower limit, where the motor then HOLDS it; an elevator going up: `1.2` |
|
|
410
|
+
| `motorStrength` | `20` | the motor's gain — acceleration per unit of speed error. **Against gravity the motor sags by g/motorStrength**: a lift at `20` rises at half the asked speed, at `100` within a tenth of it |
|
|
411
|
+
| `angle` | read-only | a hinge's current angle in degrees: the joint's OWN body turned about the axis, relative to the target. `if (hinge.angle > 60)` is "the door is open" |
|
|
412
|
+
| `travel` | read-only | a slide's current travel in metres (px): the joint's own anchor along the axis relative to the target's — `0` where the anchors coincide, which is where the scene loaded |
|
|
413
|
+
|
|
414
|
+
All three props are LIVE — a lever behaviour that writes `hinge.motorSpeed = -25`
|
|
415
|
+
starts the winch that frame — and `limits`/`angle` share one convention (the
|
|
416
|
+
joint's body relative to the target), so a limit you read off `angle` is the
|
|
417
|
+
limit you write. `examples/contraption-3d` is a gate you push, a drawbridge a
|
|
418
|
+
lever lowers, a millstone that never stops and a seesaw with stops, all from
|
|
419
|
+
JSON — and the portcullis at its keep is a `prismatic` a `bridgeDown` signal
|
|
420
|
+
lifts.
|
|
421
|
+
|
|
422
|
+
Four things a hinge taught while that example was built:
|
|
423
|
+
|
|
424
|
+
- **`angle` is the ACTUAL turn, not "0 as loaded".** A drawbridge authored
|
|
425
|
+
raised at `rotation: [85, 0, 0]` reads `angle: 85` on the first frame, and
|
|
426
|
+
its limits are absolute too: `[0, 85]` is "flat to raised". Nothing is
|
|
427
|
+
measured from the pose the scene loaded in.
|
|
428
|
+
- **Author a turned body about its hinge.** The two anchors must COINCIDE at
|
|
429
|
+
load (rule 5 below). A deck turned 85° about its near edge has its CENTRE
|
|
430
|
+
where that turn puts it — compute it (`sill + [0, 3.1·sin 85°, −3.1·cos 85°]`
|
|
431
|
+
for a 6.2 m deck), do not type it; a mismatch snaps the body into place and
|
|
432
|
+
the snap looks like a limit pushing it.
|
|
433
|
+
- **A raised thing needs HOLDING.** A hinge with no motor is free: a deck
|
|
434
|
+
authored raised falls flat on the first frame. `motorSpeed: 25` toward the
|
|
435
|
+
upper stop holds it there; the lever writes `-25` and the same motor lowers
|
|
436
|
+
it to the lower stop and holds THAT.
|
|
437
|
+
- **A jointed thing must not rest on static ground.** A millstone whose rim
|
|
438
|
+
touches the walkway slabs is jammed by friction and turns at 2°/s; a seesaw
|
|
439
|
+
whose ends rest on the landings does not tilt. Leave a gap.
|
|
440
|
+
|
|
441
|
+
A grapple is a joint made at RUNTIME: `examples/grapple-3d` casts a ray from
|
|
442
|
+
the eye, and on a static hit builds a `Joint3D` in code — `new Joint3D('Rope')`,
|
|
443
|
+
`type: 'rope'`, `target` the hit body's path, `targetAnchor` the hit point in
|
|
444
|
+
that body's frame, `length` the distance plus a little slack — and adds it
|
|
445
|
+
under the player; `free()` it to let go. The adapter picks a new joint up on
|
|
446
|
+
the next step and drops a freed one; gravity does the swing, and a rope has a
|
|
447
|
+
little give under a swinging weight (about 4% of its length).
|
|
448
|
+
|
|
449
|
+
## Vehicle3D — a car in one node
|
|
450
|
+
|
|
451
|
+
`examples/buggy-3d` proves a car can be BUILT from joints, and that building
|
|
452
|
+
one is a round's work with two traps in it. Every racing game, delivery game
|
|
453
|
+
and open world with a road wants one in a node: `Vehicle3D`, under a
|
|
454
|
+
`RigidBody3D` chassis, is Rapier's raycast vehicle — wheels on springs that
|
|
455
|
+
never touch anything (a ray each), so a car cannot catch a kerb on a wheel
|
|
456
|
+
body or disagree with its axle.
|
|
457
|
+
|
|
458
|
+
```json
|
|
459
|
+
{ "name": "Car", "type": "RigidBody3D",
|
|
460
|
+
"props": { "mass": 800, "collider": { "shape": "box", "size": [1.8, 0.6, 4] } },
|
|
461
|
+
"children": [
|
|
462
|
+
{ "name": "Drive", "type": "Vehicle3D", "props": { "wheels": [
|
|
463
|
+
{ "position": [-0.8, -0.1, 1.4], "steer": true },
|
|
464
|
+
{ "position": [ 0.8, -0.1, 1.4], "steer": true },
|
|
465
|
+
{ "position": [-0.8, -0.1, -1.4], "drive": true },
|
|
466
|
+
{ "position": [ 0.8, -0.1, -1.4], "drive": true } ] } },
|
|
467
|
+
{ "name": "Wheel0", "type": "Node3D", "props": { "position": [-0.8, -0.1, 1.4] },
|
|
468
|
+
"children": [ { "name": "Hub", "type": "Node3D", "children": [
|
|
469
|
+
{ "name": "Tyre", "type": "MeshInstance3D",
|
|
470
|
+
"props": { "mesh": "cylinder", "size": [0.35, 0.25, 0.35], "rotation": [0, 0, 90] } } ] } ] }
|
|
471
|
+
] }
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
| prop | default | what it does |
|
|
475
|
+
|---|---|---|
|
|
476
|
+
| `wheels` | `[]` | `[{ position, radius?, rest?, steer?, drive? }]` — where each spring hangs from in the CHASSIS's frame; radius 0.35, rest length 0.3; at least one |
|
|
477
|
+
| `suspensionStiffness` · `suspensionCompression` · `suspensionRelaxation` · `maxTravel` | `30` · `2.3` · `3.5` · `0.3` | the springs (20 soft, 30 a car, 60 a kart) |
|
|
478
|
+
| `friction` · `sideFriction` | `2.5` · `1` | tyre grip along and across the wheel (lower side friction = more drift) |
|
|
479
|
+
| `maxSteerDeg` · `engineForce` · `brakeForce` | `32` · `1500` · `40` | full lock, force per driven wheel at full throttle, brake per wheel |
|
|
480
|
+
| `moveAction` · `brakeAction` | `"move"` · `"brake"` | the stick (up throttle, down reverse, left/right steer) and the brake button; `moveAction: ""` = your code writes `throttle`/`steer`/`brake` (−1..1, −1..1, 0..1) |
|
|
481
|
+
|
|
482
|
+
- **Forward is +z** (the +Z-forward rule, like a skin). Put the camera behind
|
|
483
|
+
at −z and turn it with the chassis (`examples/courier-3d`'s `ChaseCam`).
|
|
484
|
+
- **The wheel nodes are posed for you.** A child of the chassis named
|
|
485
|
+
`Wheel<i>` is put at its mount, hanging by the spring's current length, and
|
|
486
|
+
turned to steer; its child `Hub` spins as the wheel rolls — so a tyre mesh
|
|
487
|
+
under `Hub`, rotated `[0, 0, 90]` to lie on the axle, just works.
|
|
488
|
+
- **`enabled: false` is parked**: the throttle and steering read nothing and
|
|
489
|
+
the brake is held, while the suspension still settles and the wheels still
|
|
490
|
+
pose. Both the car and the character read `move`, so a game with a driver
|
|
491
|
+
who can get OUT flips this and `CharacterController3D.enabled` together
|
|
492
|
+
(`examples/errands-3d`; "Letting go" in `incanto-3d-character.md`).
|
|
493
|
+
- **Readbacks.** `speed` (m/s, negative in reverse), `wheel(i)` →
|
|
494
|
+
`{ contact, suspension, rotation, steering }`.
|
|
495
|
+
- The chassis is an ordinary `RigidBody3D`: `mass` is the car's weight, its
|
|
496
|
+
`collider` is what hits walls, `angularDamping` calms a flip, and a
|
|
497
|
+
`Respawn` under it catches a drive off the map. A car on its roof is a
|
|
498
|
+
stuck game — give the player a reset (the example's `R` rights the car).
|
|
499
|
+
- **Zones are driven THROUGH.** The wheel rays ignore every `Area3D`, so a
|
|
500
|
+
checkpoint, a delivery bay or a speed trap is a sensor box over the road
|
|
501
|
+
and the car passes through it level — it does not climb onto the box.
|
|
502
|
+
- **Lift-off is `linearDamping`.** A raycast vehicle has no rolling
|
|
503
|
+
resistance: released, it coasts at CONSTANT speed until it hits something
|
|
504
|
+
(measured 14.2 m/s, unchanged over a second). `linearDamping: 0.3` on the
|
|
505
|
+
chassis is the engine braking (a ~3 s decay) and the top speed —
|
|
506
|
+
`engineForce × driven wheels / mass ÷ damping` — in one number; the
|
|
507
|
+
errands van has none and stops on its brake, the race car has it.
|
|
508
|
+
- **Online, a car is its owner's body** — `network: { mode: "owner", sync:
|
|
509
|
+
["position", "rotation", "Wheel0.rotation", "Wheel1.rotation"] }` on the
|
|
510
|
+
chassis, and the other player's car a kinematic `CharacterBody3D` copy you
|
|
511
|
+
can bump. `examples/race-mp-3d` and "An online race" in
|
|
512
|
+
`incanto-multiplayer.md`.
|
|
513
|
+
|
|
514
|
+
A vehicle is the same parts: `examples/buggy-3d` is a box chassis, four
|
|
515
|
+
cylinder wheels on `revolute` axles (the rear pair motored — forward is a
|
|
516
|
+
negative speed about +x), and two steering knuckles whose hinge motor is a
|
|
517
|
+
servo, `motorSpeed = (target − angle) · rate`, with `limits` at full lock. A
|
|
518
|
+
knuckle needs inertia (mass, a real box) and a strong motor to turn a tyre
|
|
519
|
+
that is scrubbing on the ground, and a brake is a motor asked for ~0 with a
|
|
520
|
+
high `motorStrength`.
|
|
521
|
+
|
|
522
|
+
And two things the ENGINE learned: a motorised body no longer falls asleep
|
|
523
|
+
(Rapier sleeps anything that moves slowly for a while, and a door at 30°/s is
|
|
524
|
+
slow — the motor ran two seconds and froze mid-swing), and a TURNING ground
|
|
525
|
+
now carries its rider round — the moving-platform carry was the ground's
|
|
526
|
+
origin, which a millstone never moves, so its rider stood still while the
|
|
527
|
+
floor turned under him. Both riders (a `CharacterBody3D`'s `moveAndSlide`
|
|
528
|
+
and the player's `platformCarry`), both dimensions.
|
|
357
529
|
|
|
358
530
|
## Pushing a body around (the API that was in no skill)
|
|
359
531
|
|
|
@@ -381,6 +553,13 @@ the same sign as `rotation` (clockwise, because 2D is y-down). There is no
|
|
|
381
553
|
torque call in either. Writes land on the next step — these are live props, not
|
|
382
554
|
load-time constants.
|
|
383
555
|
|
|
556
|
+
**An impulse composes with a velocity written the same frame.** A mirror
|
|
557
|
+
steering a ball by `linearVelocity`, a magnet, a conveyor — and a kick in the
|
|
558
|
+
same `update` — used to lose the kick: the write reached the solver a step
|
|
559
|
+
later and overwrote it. Now `applyImpulse` lands the pending write first, then
|
|
560
|
+
the impulse, and `linearVelocity` reads the result at once (a harness that
|
|
561
|
+
reads it right after the kick sees the kick).
|
|
562
|
+
|
|
384
563
|
`fixedRotation` is authored, not live: it locks the body's rotation when the
|
|
385
564
|
body is CREATED. Changing it mid-run does nothing, and a locked body's
|
|
386
565
|
`angularVelocity` is zero rather than whatever was authored.
|
|
@@ -414,6 +593,25 @@ exist on `RigidBody2D` in the same terms. "At rest" is yours to decide: read
|
|
|
414
593
|
`physics.velocityOf(ball)` and call it stopped under ~0.05 for a few frames.
|
|
415
594
|
`examples/minigolf-3d` is the composition.
|
|
416
595
|
|
|
596
|
+
### A rudder: write `angularVelocity`
|
|
597
|
+
|
|
598
|
+
`body.angularVelocity = [0, rate, 0]` (or `body.angularVelocity[1] = rate`)
|
|
599
|
+
spins a `RigidBody3D` from that step on, in rad/s about the world axes —
|
|
600
|
+
`+y` turns +z toward +x. It is how `examples/harbor-3d` steers a boat, and
|
|
601
|
+
it did NOTHING in 3D until the harbor round: the adapter read the solver's
|
|
602
|
+
spin back every step and never looked at a script's write (2D always had the
|
|
603
|
+
path).
|
|
604
|
+
A `fixedRotation` body ignores it, as it ignores an authored spin.
|
|
605
|
+
|
|
606
|
+
### A crate that floats (`buoyancy`)
|
|
607
|
+
|
|
608
|
+
`RigidBody3D.buoyancy: 1` and a `Water3D` under it: the body is lifted by the
|
|
609
|
+
submerged part of its collider's volume at water's density, so `mass` against
|
|
610
|
+
the collider decides — a 1 m³ crate at 150 kg floats, at 3000 kg it sinks, a
|
|
611
|
+
wide hull rights itself, and it rides the water's waves. `Water3D.drag` slows
|
|
612
|
+
it. The whole rule, with the numbers, is in `incanto-environment.md` ("Things
|
|
613
|
+
that float"); `examples/harbor-3d` drives a boat with it.
|
|
614
|
+
|
|
417
615
|
## What is inside this Area right now?
|
|
418
616
|
|
|
419
617
|
`triggerEnter(other)` / `triggerExit(other)` tell you about CROSSINGS. A pressure
|
|
@@ -435,10 +633,11 @@ question you usually mean.
|
|
|
435
633
|
|
|
436
634
|
## Joints: the three things that decide whether yours works
|
|
437
635
|
|
|
438
|
-
1. **
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
636
|
+
1. **A hinge is `revolute` with an `axis`** (3D; 2D's needs no axis). Before it
|
|
637
|
+
existed the recipe was two `spherical` joints along the axis — that still
|
|
638
|
+
works, but it has no stops and no motor, and the sentence "there is no hinge
|
|
639
|
+
in 3D" was true for a year. A seesaw, a door, a wheel and a drawbridge are one
|
|
640
|
+
joint each now.
|
|
442
641
|
2. **`collide` decides whether the two ends touch, and its default reads the
|
|
443
642
|
type.** Rapier lets jointed bodies collide, and a hinge wants its bodies
|
|
444
643
|
overlapping at the pivot — so the contact solver fights the joint and flings
|
|
@@ -460,13 +659,35 @@ question you usually mean.
|
|
|
460
659
|
it belongs to — is now a load-time error: Rapier accepts a body jointed to
|
|
461
660
|
itself and constrains nothing, and the rope fell exactly as if it were not
|
|
462
661
|
there.
|
|
463
|
-
4.
|
|
662
|
+
4. **A joint's own bodies are not the world to the character controller.**
|
|
663
|
+
A `CharacterBody3D` crane hook with a chain of jointed links hanging off it
|
|
664
|
+
used to RISE on its own (the controller's shape-cast saw the first link
|
|
665
|
+
inside the hook's box and pushed the hook out of it, every step) and, parked
|
|
666
|
+
over the next tower with its chain, landed twice as far as asked (the
|
|
667
|
+
controller's ground ray hit that same link, so the moving-platform carry
|
|
668
|
+
dragged the hook along by however far the link had moved — which was however
|
|
669
|
+
far the hook had just moved). Now a body jointed to a character with the
|
|
670
|
+
pair's contacts off is not its obstacle, and a body jointed to it is never
|
|
671
|
+
its ground. So the natural crane — a kinematic hook you drive with
|
|
672
|
+
`moveAndSlide`, a chain and a ball on `spherical` joints — just works;
|
|
673
|
+
`examples/wreck-3d` is that crane. Both dimensions.
|
|
674
|
+
5. **The two bodies must AGREE about the axis.** A hinge's or a slide's `axis`
|
|
675
|
+
is one vector, and Rapier builds a frame from it in EACH body's local space.
|
|
676
|
+
So the two bodies' rotations may differ only by a turn ABOUT that axis: a
|
|
677
|
+
drawbridge authored raised about its own hinge is fine; a wheel body turned
|
|
678
|
+
on its side to lay its cylinder along the axle is not — the chassis reads
|
|
679
|
+
`[1,0,0]` as world x and the wheel reads it as world y, and a buggy built
|
|
680
|
+
that way went onto its roof on the first frame. A joint that breaks the
|
|
681
|
+
rule is refused at creation, by name, with the fix: keep the body
|
|
682
|
+
unrotated and turn the MESH inside it (`collider: auto` follows the mesh) —
|
|
683
|
+
`examples/buggy-3d` is four wheels built exactly so.
|
|
684
|
+
6. **`anchor`/`targetAnchor` are local offsets from each body's ORIGIN**,
|
|
464
685
|
resolved against the bodies' positions at load. If the two disagree the solver
|
|
465
686
|
snaps the body into place on the first frame, and a 4 cm typo is silent. Author
|
|
466
687
|
them with arithmetic (generate the scene) rather than by hand.
|
|
467
688
|
|
|
468
|
-
|
|
469
|
-
|
|
689
|
+
Limits and motors live on `revolute` (see the table above) — a lever's travel
|
|
690
|
+
is `limits`, not static geometry in its way.
|
|
470
691
|
|
|
471
692
|
## Raycasts
|
|
472
693
|
|
|
@@ -539,6 +760,13 @@ and physics rebuilds the body that step. In-place mutation of the existing
|
|
|
539
760
|
collider object (`body.collider.size[0] = w`) is NOT watched (the per-step
|
|
540
761
|
change scan was removed for performance).
|
|
541
762
|
|
|
763
|
+
**To switch a body OFF, write `enabled = false`** — not `collider = null`. The
|
|
764
|
+
prop is an object and a non-object used to reach the physics sync and throw
|
|
765
|
+
`null is not an object (evaluating 'node.collider.shape')` out of the frame
|
|
766
|
+
loop, with no node path: the loader refuses `"collider": null` in JSON with a
|
|
767
|
+
typed error, and a runtime write was checked nowhere. It is now refused with
|
|
768
|
+
that sentence, naming the node, and the collider it had is kept.
|
|
769
|
+
|
|
542
770
|
## Debug drawing
|
|
543
771
|
|
|
544
772
|
`physics.debugDraw = true` (the instance `enablePhysics2D/3D` returns) renders
|
|
@@ -71,10 +71,10 @@ the hazards and the destinations down with it.
|
|
|
71
71
|
| `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 |
|
|
72
72
|
| `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. |
|
|
73
73
|
| `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 |
|
|
74
|
-
| `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) |
|
|
74
|
+
| `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) — of the node's EXTENT, not its origin, so standing on a 30 m quay counts as reaching it. A node earns a place on this list by carrying a group, running a behaviour, or being a trigger volume; measured against origins, every tagged floor in every 3D game reported as unreached while the player stood on it |
|
|
75
75
|
| `never fired` | signals a `connections[]` entry listens to that never happened |
|
|
76
76
|
| `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 |
|
|
77
|
-
| `danger` | how many times the player's `Health` emitted `damaged` — and when that is ZERO, WHICH zero it is
|
|
77
|
+
| `danger` | how many times the player's `Health` emitted `damaged` — and when that is ZERO, WHICH zero it is. A scene with no `DamageOnContact` AND no player-side `Health` is a walkabout and worth shouting about; one with a Health but no hits says so WITHOUT claiming the scene is harmless, because damage that comes from a behaviour calling `Health.damage()` has no hazard node to count and this cannot see it; a scene full of hazards 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. **Put the player's `Health` under the player** — on it or on a child, the way `star-survivor` composes `/Game/Player/Vitals` — or none of this counts it: the oracle asks whether the damaged node is player-SIDE. |
|
|
78
78
|
|
|
79
79
|
**The bot presses the HUD.** Every focusable widget a player can currently see —
|
|
80
80
|
`UiButton`, `UiToggle`, `UiSelect`, `UiSlider` — gets pressed occasionally,
|
|
@@ -100,10 +100,19 @@ readable if you want it for something else.
|
|
|
100
100
|
`deserialize` is defensive on purpose: that data may come from a build of your
|
|
101
101
|
game that shipped six weeks ago. Check what you read.
|
|
102
102
|
|
|
103
|
-
Built-ins that already save: `
|
|
104
|
-
`
|
|
103
|
+
Built-ins that already save: `Checkpoint` (which one was lit),
|
|
104
|
+
`Collector` (total), `Currency` (amount), `DayNight` (hour, paused),
|
|
105
|
+
`Health` (current, dead), `Phases` (which phase the fight reached),
|
|
106
|
+
`SavePoint` (playtime, and the poses it `keep`s),
|
|
105
107
|
`ScoreKeeper` (score, lives, won/lost), `WaveSpawner` (which wave).
|
|
106
108
|
|
|
109
|
+
`Checkpoint` restores the `Respawn` redirect as well as its own `lit` flag, and
|
|
110
|
+
when a run lit several it comes back to the one it reached LAST — the order they
|
|
111
|
+
announce in does not decide where you land. Without that, a continued run's
|
|
112
|
+
first fall went back to the level's START, past every checkpoint the session had
|
|
113
|
+
reached, and the report was green because a behaviour with no `serialize` is not
|
|
114
|
+
counted as saveable in the first place.
|
|
115
|
+
|
|
107
116
|
`WaveSpawner` RESTARTS the wave you were on rather than resuming mid-spawn —
|
|
108
117
|
spawned entities are never restored, so resuming "four enemies into wave 3"
|
|
109
118
|
would resume a wave whose enemies do not exist. `waveStarted` fires again, so a
|
|
@@ -196,6 +205,24 @@ At runtime `engine.captureState()` logs the same thing per node.
|
|
|
196
205
|
|
|
197
206
|
Never hand-craft a uid. Use `newUid()`.
|
|
198
207
|
|
|
208
|
+
## The two-button version
|
|
209
|
+
|
|
210
|
+
For a game whose save is "this scene, right now", `GameFlow` has the verbs and
|
|
211
|
+
you write no TypeScript at all:
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{ "from": "/root/HUD/PauseMenu/Save", "signal": "pressed", "to": "/root/Flow", "handler": "save" },
|
|
215
|
+
{ "from": "/root/HUD/PauseMenu/Continue", "signal": "pressed", "to": "/root/Flow", "handler": "continueFrom" }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
with `"script": { "name": "GameFlow", "props": { "saveSlots": "beacon-isle" } }`
|
|
219
|
+
naming where the slots live. `save(slot)` writes `captureState()` under the
|
|
220
|
+
scene's own name; `continueFrom(slot)` reloads the scene and restores AFTER
|
|
221
|
+
`onReady`, which is the ordering everything below is about; `hasSave(slot)` is
|
|
222
|
+
what a CONTINUE button asks before it offers itself. A slot written by ANOTHER
|
|
223
|
+
scene is refused with a sentence — routing between levels is yours, and the rest
|
|
224
|
+
of this page is how to do it. Composed in `examples/beacon-isle-3d`.
|
|
225
|
+
|
|
199
226
|
## Saving and loading
|
|
200
227
|
|
|
201
228
|
```ts
|
|
@@ -284,6 +311,38 @@ const game = await createGame2D({ canvas, scene });
|
|
|
284
311
|
// then `restoreOnReady: true` on that scene's SavePoint, or call restore()
|
|
285
312
|
```
|
|
286
313
|
|
|
314
|
+
## Where the player was standing: `keep`
|
|
315
|
+
|
|
316
|
+
A save is behavior state keyed by uid and deliberately not a snapshot of the
|
|
317
|
+
tree, so **mid-level positions do not come back**. That is right for a
|
|
318
|
+
checkpoint save and wrong for a Continue: pressing it should put you on the
|
|
319
|
+
street you quit on, not at the depot. The escape hatch was one sentence —
|
|
320
|
+
"a game that wants a position saves it" — and every game wrote the same
|
|
321
|
+
behaviour whose only job is to hold a transform.
|
|
322
|
+
|
|
323
|
+
The scene says it instead:
|
|
324
|
+
|
|
325
|
+
```json
|
|
326
|
+
{ "name": "Save", "type": "Node3D", "uid": "n_…",
|
|
327
|
+
"script": { "name": "SavePoint",
|
|
328
|
+
"props": { "game": "longhaul", "slot": "1",
|
|
329
|
+
"keep": ["/root/Courier", "/root/Van"] } } }
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Each named node's `position` and `rotation` go into the slot alongside the
|
|
333
|
+
playtime, and come back in `announce()` — after the whole restore pass, so a
|
|
334
|
+
camera rig or a controller restoring on the same frame cannot overwrite them.
|
|
335
|
+
|
|
336
|
+
- **Each kept node needs a `uid`**, the same join key as the rest of the save,
|
|
337
|
+
so a rename or a reparent survives. A path that names nothing, a node with no
|
|
338
|
+
uid, and a node with no position each say so through `engine.log` on the way
|
|
339
|
+
IN — at save time, when you can still fix it — rather than restoring quietly
|
|
340
|
+
to the wrong place months later.
|
|
341
|
+
- A slot written before a node was added to `keep` restores everything else and
|
|
342
|
+
leaves that node where the scene file puts it.
|
|
343
|
+
- It is a POSE, not physics: velocity is not kept, so a continued run starts at
|
|
344
|
+
rest. Save a velocity yourself if a game needs one.
|
|
345
|
+
|
|
287
346
|
A `SavePoint` records `scene` as the scene's own `name` unless you set the prop,
|
|
288
347
|
so `level2.scene.json` named `level2` needs no wiring at all. Set `scene`
|
|
289
348
|
explicitly when one file is entered more than one way (`"chapter-2-rescue"`).
|
|
@@ -367,6 +367,19 @@ that do exist at the failing spot).
|
|
|
367
367
|
registered (`registerBehavior`) before `loadScene` → `UNKNOWN_BEHAVIOR` otherwise.
|
|
368
368
|
- Unresolvable `from`/`to` → `DANGLING_CONNECTION` at load. Renaming a node breaks its
|
|
369
369
|
connections **loudly** — update paths in the same edit.
|
|
370
|
+
- `args` calls the handler with THOSE values instead of whatever the signal
|
|
371
|
+
emitted — `Function.bind` for a wire:
|
|
372
|
+
|
|
373
|
+
```json
|
|
374
|
+
{ "signal": "phase2", "from": "Boss/Mood", "to": "HUD/Banner", "handler": "show",
|
|
375
|
+
"args": ["THE CINDERS CATCH", { "color": "#ffb454", "seconds": 1.6 }] }
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Without it, "when this happens, say that" needed a two-line behaviour whose
|
|
379
|
+
whole job was calling one method with one constant. Bound arguments REPLACE
|
|
380
|
+
the emitted ones — a wire that half-carried would be ambiguous, and the
|
|
381
|
+
emitted values are still one connection away (the same wire without `args`).
|
|
382
|
+
It must be a LIST: `"args": "ENRAGED"` is a load error.
|
|
370
383
|
- `filter` gates firing on the first emitted argument: it must be a node in `filter.group`
|
|
371
384
|
and/or match every `filter.tag` entry. **A filter's keys are `group` and `tag`, and a
|
|
372
385
|
connection's are `[signal, from, to, handler, once, filter]`** — anything else is a load
|
|
@@ -22,9 +22,17 @@ each answers a failure that used to look like nothing at all:
|
|
|
22
22
|
| `assetErrors()` | a model/texture 404'd — the thing is simply not there | §6c |
|
|
23
23
|
| `framing` | the camera is pointed the wrong way, or nothing is lit | §6a |
|
|
24
24
|
|
|
25
|
-
A run that finishes is not a run that worked. `runScript()` fails the run
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
A run that finishes is not a run that worked. `runScript()` fails the run on
|
|
26
|
+
BOTH halves of "the engine stayed alive": behaviours the loop quarantined after
|
|
27
|
+
a throw (`stats().errors`), and anything the engine itself reported as an
|
|
28
|
+
**error** — a sentence it wrote precisely because it chose to keep going. Each
|
|
29
|
+
distinct one is a failure quoting what was said, deduped, so a per-frame
|
|
30
|
+
complaint is one fault rather than six hundred. Measured on a boat whose
|
|
31
|
+
`Buoyancy` hung on a plain `Node` instead of the hull: the engine said
|
|
32
|
+
`needs a body it can push … it will never float` by name every frame, and the
|
|
33
|
+
run used to report `ok: true` while the boat sank through the sea. Warnings are
|
|
34
|
+
printed and do NOT fail; `framing` and `assetErrors()` you still have to ASK
|
|
35
|
+
for.
|
|
28
36
|
|
|
29
37
|
## 0. The whole ladder, one command: `incanto-verify`
|
|
30
38
|
|
|
@@ -187,6 +195,42 @@ Script steps' `do:`/`assert:` callbacks receive the full RunContext —
|
|
|
187
195
|
work inside runScript: `do: (ctx) => ctx.engine.input.setActionVector('move',
|
|
188
196
|
chase(ctx), 0)`.
|
|
189
197
|
|
|
198
|
+
**A sound can be asked where it came from.** `engine.audio.recent()` entries
|
|
199
|
+
carry `pan`, `gain` and `distance` for spatial sounds, computed from the scene's
|
|
200
|
+
camera when nothing has rendered — so a game whose mechanic is a noise in the
|
|
201
|
+
dark is checkable (`examples/earshot-3d`).
|
|
202
|
+
|
|
203
|
+
**A HUD widget can be asked where it is.** Every headless run arms the camera's
|
|
204
|
+
projection (`engine.toScreen` / `toWorld`), so `UiWaypoint.screen()` answers
|
|
205
|
+
instead of returning null — a marker that pins to the screen edge when its
|
|
206
|
+
target is behind you is now something a harness can check.
|
|
207
|
+
|
|
208
|
+
**A two-player game is driven by two hands.** The bot finds every
|
|
209
|
+
`CharacterController2D`/`3D` in the scene and drives each one on the action set
|
|
210
|
+
it declares (`moveAction`, `jumpAction`), so a couch co-op game gets both
|
|
211
|
+
characters walking. It used to drive the first `vector2` action it found:
|
|
212
|
+
measured on a two-player scene, player one walked 17.3 m and player two walked
|
|
213
|
+
0.0 m, and the report said the game could never be won.
|
|
214
|
+
|
|
215
|
+
**Watching every frame: `onFrame`.** Steps fire at named times, which is the
|
|
216
|
+
wrong shape for anything continuous — the APEX of a jump, the CLOSEST a chaser
|
|
217
|
+
ever got, whether a shell cleared the wall between two named moments:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
let apex = 0;
|
|
221
|
+
await runScript(scene, {
|
|
222
|
+
durationMs: 4000,
|
|
223
|
+
onFrame: (ctx) => {
|
|
224
|
+
apex = Math.max(apex, (ctx.getNode('Ball') as { position: number[] }).position[1] ?? 0);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
It runs after every simulated frame with the live context, and a throw inside it
|
|
230
|
+
is a reported failure like a throw in a step. Before it existed every harness in
|
|
231
|
+
this repo wrote the same workaround — one `do:` that stepped the engine by hand
|
|
232
|
+
in a loop, which moves the clock out from under the script's own steps.
|
|
233
|
+
|
|
190
234
|
**Reaching nodes & state from a step (the gotchas a sim hit):**
|
|
191
235
|
- `ctx.getNode(path)` and `ctx.scene.root.getNode(path)` resolve from the ROOT.
|
|
192
236
|
When the root node IS named `Game`, the root is `ctx.scene.root` (or
|
|
@@ -301,6 +345,16 @@ naming a path that resolves to nothing is a FAILURE, not a click into space.
|
|
|
301
345
|
`geometricPicker(scene)` from `incanto/test` is the same thing on its own, for a
|
|
302
346
|
harness that wants to ask "what is under this point" directly.
|
|
303
347
|
|
|
348
|
+
**Click the THING, not one of its triangles.** Anything bigger than a cube is a
|
|
349
|
+
grouping node with meshes under it and the behaviour on the group — so
|
|
350
|
+
`click: 'Keep/Ballista'` aims at whatever that subtree DRAWS, and a click that
|
|
351
|
+
lands on a different part of the same clickable is not a miss. Both of those
|
|
352
|
+
used to be failures: the group answered `resolves to no node — nothing to point
|
|
353
|
+
at` (a grouping node has no framing entry of its own), and naming a child was
|
|
354
|
+
reported as a miss whenever the ray met its sibling first, while the click had
|
|
355
|
+
already gone through the real `Clickable` and fired it. A click is wrong when it
|
|
356
|
+
reaches a different HANDLER — a wall in the way still says so.
|
|
357
|
+
|
|
304
358
|
**A `click` naming a HUD widget PRESSES it** — `click: 'Hud/Shop/Buy'` is the
|
|
305
359
|
whole verb for a menu, a shop, a settings screen, an idle game or a card game.
|
|
306
360
|
There is no cursor involved: a widget lives in the DOM overlay, not the world,
|
|
@@ -1034,6 +1088,35 @@ Rules that make it hold (the engine's own rules anyway): use `engine.rng`
|
|
|
1034
1088
|
never Math.random, dt/`engine.time` never Date.now. Gamepads replay through
|
|
1035
1089
|
the ACTIONS they were bound to, not raw pad state.
|
|
1036
1090
|
|
|
1091
|
+
### The same machinery is a REWIND
|
|
1092
|
+
|
|
1093
|
+
Determinism means a rewind needs no snapshot: cut the tape, load the scene
|
|
1094
|
+
again, and re-run the ticks that are left. A crate that was mid-air comes back
|
|
1095
|
+
mid-air, because it is the same simulation and not a recording of where things
|
|
1096
|
+
were.
|
|
1097
|
+
|
|
1098
|
+
```ts
|
|
1099
|
+
const tape = startRecording(game.engine);
|
|
1100
|
+
|
|
1101
|
+
function takeBack(seconds: number): void {
|
|
1102
|
+
const upTo = tape.rewind(tape.take().ticks.length - Math.round(seconds * 60));
|
|
1103
|
+
game.engine.setScene(loadScene(structuredClone(sceneJson), { engine: game.engine }));
|
|
1104
|
+
replay(game.engine, upTo); // …and the tape keeps recording from the cut
|
|
1105
|
+
}
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
- `tape.take()` reads the recording WITHOUT ending it — `stop()` detaches the
|
|
1109
|
+
taps, so reading used to mean stopping, and the next recording's tick indices
|
|
1110
|
+
restarted at zero.
|
|
1111
|
+
- `tape.rewind(n)` drops everything after tick `n`, returns the tape up to it,
|
|
1112
|
+
and keeps recording from there — so a game can rewind, play on, and rewind
|
|
1113
|
+
again.
|
|
1114
|
+
- `replay(engine, recording, { until })` stops where you say, for a scrub or a
|
|
1115
|
+
mid-run assertion.
|
|
1116
|
+
|
|
1117
|
+
Measured in `examples/back-3d`: a 150-tick prefix replayed into a freshly
|
|
1118
|
+
loaded scene reproduces both crates and the walker to the millimetre.
|
|
1119
|
+
|
|
1037
1120
|
|
|
1038
1121
|
## Sound: did the right thing sound?
|
|
1039
1122
|
|
|
@@ -44,3 +44,15 @@ and `Walker` (CharacterBody3D + `Chase` on a child, `slopeLimitDeg 50`, `stepHei
|
|
|
44
44
|
the west slope, `loseRange 25` so it never joins the quest. `verify.ts` measures the rebound
|
|
45
45
|
(2.4 m of 3), the chase up the slope (8.5 m of 10, +1.75 m) and that the walker stays on the
|
|
46
46
|
terrain (0.97 m over `heightAt`).
|
|
47
|
+
|
|
48
|
+
## Pausing, saving, continuing
|
|
49
|
+
|
|
50
|
+
ESC pauses and shows the pause menu; SAVE writes the run into a slot and
|
|
51
|
+
CONTINUE reloads the isle and hands every behaviour its state back — both are
|
|
52
|
+
`GameFlow` handlers (`save`, `continueFrom`) wired from buttons, so there is no
|
|
53
|
+
save code in this game at all.
|
|
54
|
+
|
|
55
|
+
ESC pauses and shows the pause menu; OPTIONS opens the settings over it and
|
|
56
|
+
BACK returns to the menu that asked. All of it is `GameFlow` (`pausePanelPath`,
|
|
57
|
+
`screen(path)`, `back()`) wired with `connections` — there is no TypeScript for
|
|
58
|
+
any screen in this game.
|