incanto 0.66.0 → 0.68.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 +87 -0
- package/bin/_pick-server.mjs +48 -0
- package/bin/incanto-check.mjs +16 -2
- package/bin/incanto-editor.mjs +39 -2
- package/bin/incanto-feel.mjs +4 -1
- package/bin/incanto-frame.mjs +37 -3
- package/bin/incanto-logs.mjs +23 -3
- package/bin/incanto-multiplay.mjs +50 -1
- package/bin/incanto-play.mjs +3 -1
- package/bin/incanto-playtest.mjs +3 -25
- package/bin/incanto-verify.mjs +15 -2
- package/dist/2d.d.ts +44 -7
- package/dist/2d.js +6 -4
- package/dist/3d.d.ts +112 -16
- package/dist/3d.js +8 -6
- package/dist/{audio-player-_UAcHxnC.d.ts → audio-player-DOrq7sP-.d.ts} +62 -4
- package/dist/{behavior-BXNLfIJk.d.ts → behavior-DoFPYrgo.d.ts} +117 -2
- package/dist/boot-failure-CKYrEcGF.js +47 -0
- package/dist/{create-game-DYJCIzO0.js → create-game-DbWtVTxD.js} +91 -29
- package/dist/{create-game-Dd7H4bJV.js → create-game-IZIydDwI.js} +16 -10
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +3 -2
- package/dist/{duplicate-CqSAtdrh.js → duplicate-IWIqk0HJ.js} +1 -1
- package/dist/{editor-switch-DAvWQeld.d.ts → editor-switch-CnIOiyNJ.d.ts} +1 -1
- package/dist/editor.js +18 -7
- package/dist/env.d.ts +1 -1
- package/dist/{environment-presets-BlPsEmq6.js → environment-presets-CybQXNqS.js} +100 -11
- package/dist/{gameplay-D1RADWu3.js → gameplay-DM1eu_cV.js} +46 -5
- package/dist/gameplay.d.ts +75 -6
- package/dist/gameplay.js +2 -2
- package/dist/index.d.ts +52 -8
- package/dist/index.js +9 -7
- package/dist/{loader-lQDCwNag.js → loader-BC4PNtJX.js} +173 -1
- package/dist/{loader-Cga7FVP4.d.ts → loader-CcB533FR.d.ts} +2 -2
- package/dist/net.d.ts +2 -2
- package/dist/net.js +1 -1
- package/dist/{physics-2d-Cji5A6sX.js → physics-2d-B7Y6dPZO.js} +29 -2
- package/dist/{physics-3d-BkHjwJgI.js → physics-3d-bG3n70Ky.js} +30 -3
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/{register-BmuqYTiY.js → register-3ta-2Xig.js} +148 -22
- package/dist/{register-D0CxCveZ.js → register-ibCjm-wH.js} +3 -3
- package/dist/{replay-IsZbNu6d.js → replay-CYvhVHHN.js} +28 -4
- package/dist/{replay-BHoB6fCU.d.ts → replay-Dvn8aeBd.d.ts} +38 -4
- package/dist/{schema-CFeioQRE.d.ts → schema-B6ugCV1Q.d.ts} +11 -0
- package/dist/{split-screen-D_i7GRcY.js → split-screen-CYwDkbLF.js} +2 -2
- package/dist/{split-screen-eJUFjhi1.d.ts → split-screen-DhrSzZIB.d.ts} +3 -3
- package/dist/{src-BTLbXFPZ.js → src-D7RIqXYF.js} +1 -1
- package/dist/{teardown-byR9USax.js → teardown-D2NEmxPB.js} +2 -2
- package/dist/{test-DgrD0jHD.js → test-8hoHeRmo.js} +140 -32
- package/dist/test.d.ts +9 -7
- package/dist/test.js +3 -3
- package/dist/{touch-DESwnpOc.js → touch-DEAmqGdf.js} +30 -186
- package/dist/vite.d.ts +0 -7
- package/dist/vite.js +13 -3
- package/dist/webgl-unavailable-N9nQqesw.js +228 -0
- package/editor/assets/{agent8-9N-Pd_YS.js → agent8-BrrHOjMJ.js} +1 -1
- package/editor/assets/debug-BBhKuBNV.js +3 -0
- package/editor/assets/{index-CeDhIPTC.js → index-eVd0BToA.js} +92 -92
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +5 -1
- package/skills/incanto-3d-character.md +1 -0
- package/skills/incanto-audio.md +49 -0
- package/skills/incanto-building-2d-games.md +37 -0
- package/skills/incanto-building-3d-games.md +56 -0
- package/skills/incanto-editor.md +5 -1
- package/skills/incanto-environment.md +2 -2
- package/skills/incanto-gameplay-behaviors.md +29 -2
- package/skills/incanto-hud.md +14 -1
- package/skills/incanto-multiplayer.md +14 -0
- package/skills/incanto-node-reference.md +2 -1
- package/skills/incanto-performance.md +35 -0
- package/skills/incanto-physics-and-input.md +23 -0
- package/skills/incanto-playtesting.md +15 -1
- package/skills/incanto-save-slots.md +21 -1
- package/skills/incanto-scene-json-authoring.md +12 -1
- package/skills/incanto-verifying-your-game.md +19 -0
- package/skills/incanto-your-first-game.md +4 -4
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/game.scene.json +38 -19
- package/templates-app/beacon-isle-3d/src/main.ts +8 -1
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/platformer-2d/src/game.scene.json +99 -53
- package/templates-app/platformer-2d/src/main.ts +8 -1
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/star-survivor/src/game.scene.json +31 -17
- package/templates-app/star-survivor/src/main.ts +8 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/game.scene.json +14 -8
- package/templates-app/tps-3d/src/main.ts +8 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/grove.scene.json +20 -11
- package/templates-app/village-quest-3d/src/main.ts +8 -1
- package/templates-app/village-quest-3d/src/village.scene.json +10 -7
- package/editor/assets/debug-CkbJICYp.js +0 -3
package/editor/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Incanto Scene Editor</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%236ee7dc'/><text x='8' y='12' text-anchor='middle' font-size='11' font-family='monospace' fill='%230e1018'>i</text></svg>" />
|
|
8
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-eVd0BToA.js"></script>
|
|
9
9
|
<link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -2378,6 +2378,10 @@
|
|
|
2378
2378
|
"type": "boolean",
|
|
2379
2379
|
"default": true
|
|
2380
2380
|
},
|
|
2381
|
+
"platformCarry": {
|
|
2382
|
+
"type": "boolean",
|
|
2383
|
+
"default": true
|
|
2384
|
+
},
|
|
2381
2385
|
"turnSpeed": {
|
|
2382
2386
|
"type": "number",
|
|
2383
2387
|
"default": 100
|
|
@@ -7680,7 +7684,7 @@
|
|
|
7680
7684
|
},
|
|
7681
7685
|
"focusable": {
|
|
7682
7686
|
"type": "boolean",
|
|
7683
|
-
"default":
|
|
7687
|
+
"default": true
|
|
7684
7688
|
},
|
|
7685
7689
|
"draggable": {
|
|
7686
7690
|
"type": "boolean",
|
|
@@ -54,6 +54,7 @@ did not until now.
|
|
|
54
54
|
| `maxJumps` | `1` | `2` = double jump. Extra jumps work in mid-air. |
|
|
55
55
|
| `airControl` | `0.2` | how much ground control you keep airborne (0 = committed, 1 = full). |
|
|
56
56
|
| `fallGravity` | `2.5` | gravity multiplier while falling. Higher = snappier arc. |
|
|
57
|
+
| `platformCarry` | `true` | ride whatever you are standing on. Off = the controller steers in WORLD space, so standing still on a moving floor brakes you off it (measured: 95.9% of a moving deck's travel kept, 0.0% with this off). |
|
|
57
58
|
|
|
58
59
|
Every one defaults to OFF (`0` / `1`) or to the previous hard-coded constant, so
|
|
59
60
|
a scene that asks for nothing behaves exactly as before.
|
package/skills/incanto-audio.md
CHANGED
|
@@ -396,6 +396,55 @@ changed under it. `engine.music` deliberately survives a scene swap (it belongs
|
|
|
396
396
|
to the engine, not the scene): call `engine.music.stop(1)` or `crossfadeTo` when
|
|
397
397
|
the music should change.
|
|
398
398
|
|
|
399
|
+
## Sounds on a GRID: rhythm games and anything charted to a soundtrack
|
|
400
|
+
|
|
401
|
+
A sound fired from `update()` cannot land closer than one frame to where a chart
|
|
402
|
+
wants it — 16.67 ms at 60 Hz, 33.33 ms at 30, and that is the whole difference
|
|
403
|
+
between a rhythm game that feels tight and one that does not. The engine's frame
|
|
404
|
+
clock is exact (3600 steps land on `engine.time` 60.000000000 s and beat error
|
|
405
|
+
never compounds); the last 16 ms is the gap.
|
|
406
|
+
|
|
407
|
+
Schedule on the AUDIO clock instead. Queue a short LOOKAHEAD ahead of now, every
|
|
408
|
+
frame, and let Web Audio place the sound:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
const LEAD = 0.08; // schedule this far ahead of the speaker
|
|
412
|
+
let cursor = 0; // next un-queued note
|
|
413
|
+
|
|
414
|
+
engine.updated.connect(() => {
|
|
415
|
+
const horizon = engine.sfx.now + LEAD;
|
|
416
|
+
while (cursor < chart.length && startedAt + chart[cursor].atSec <= horizon) {
|
|
417
|
+
hit.play(startedAt + chart[cursor].atSec); // AudioPlayer.play(at)
|
|
418
|
+
cursor++;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- **`engine.sfx.now`** — seconds on the audio clock, the one the sound is
|
|
424
|
+
actually placed on. `0` headless and before the first sound.
|
|
425
|
+
- **`AudioPlayer.play(at)`** and **`engine.sfx.play(params, gain, { when })`** —
|
|
426
|
+
PRESETS only. A `src` clip goes through an `<audio>` element, which has no
|
|
427
|
+
scheduling clock; `at` is ignored for one rather than approximated.
|
|
428
|
+
- A time already past plays immediately (Web Audio's own rule), so a scheduler
|
|
429
|
+
that ran late is late, not silent.
|
|
430
|
+
- Everything else still applies: the bus gain, `engine.audio.recent()`, and
|
|
431
|
+
`muted`. A scheduler hand-rolled on a raw `AudioContext` hits the same
|
|
432
|
+
accuracy and loses all three.
|
|
433
|
+
|
|
434
|
+
Measured: 0 of 64 notes were given a scheduled start on the frame path; the same
|
|
435
|
+
64 queued this way land at **|mean| 0.000000 ms**.
|
|
436
|
+
|
|
437
|
+
**Charting against a music FILE** needs the playhead of the thing you can hear,
|
|
438
|
+
not a counter you started next to `music.play()` (a different clock, which keeps
|
|
439
|
+
counting through a gesture-block, a stall or a seek):
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
const songSeconds = engine.music.playhead; // null = nothing playing, or no clock
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
It is the raw element time, so a looping track wraps to 0 each pass — a loop
|
|
446
|
+
boundary to chart against, not a fault.
|
|
447
|
+
|
|
399
448
|
## Decision guide
|
|
400
449
|
|
|
401
450
|
- **Need a quick game sound (coin/jump/hit/explosion/…)** → set `preset`. Done.
|
|
@@ -485,3 +485,40 @@ which is why a wall jump was not awkward to write but impossible.
|
|
|
485
485
|
headless bot sessions and reports what could not be reached, which wires never
|
|
486
486
|
fired, and whether anything in the level can hurt the player. See
|
|
487
487
|
`incanto-playtesting.md`.
|
|
488
|
+
|
|
489
|
+
## When the boot FAILS: say so, or the bar sits at 100% forever
|
|
490
|
+
|
|
491
|
+
`createGame2D`/`createGame2D` reject like any promise, and the loading overlay
|
|
492
|
+
every starter shows is removed on the line *after* the await. So a boot that
|
|
493
|
+
fails leaves the bar at 100% with no text, and the reason in a console the
|
|
494
|
+
player will never open. Measured with WebGL denied on a pristine scaffold: the
|
|
495
|
+
overlay was still reading `Emberwood 100%` 8.6 seconds later.
|
|
496
|
+
|
|
497
|
+
One `.catch` fixes it, and every shipped template now carries it:
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
import { createGame2D, showBootFailure } from 'incanto/2d';
|
|
501
|
+
|
|
502
|
+
const game = await createGame2D({ canvas, scene: sceneJson }).catch((e) => {
|
|
503
|
+
showBootFailure(e); // paints the reason into #loading (or its own panel)
|
|
504
|
+
throw e;
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
document.querySelector('#loading')?.remove();
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**No WebGL is the case worth naming** — the 2D renderer is WebGL too. A machine
|
|
511
|
+
with acceleration off, a blocklisted driver, a locked-down or headless browser, or a page that already
|
|
512
|
+
holds too many contexts cannot make one, and three throws
|
|
513
|
+
`Error('Error creating WebGL context.')` with nothing to branch on. The engine
|
|
514
|
+
rethrows it as an `IncantoError` with code **`WEBGL_UNAVAILABLE`** and a message
|
|
515
|
+
naming the likely causes, so you can show a real screen:
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
import { isWebGLAvailable } from 'incanto/2d';
|
|
519
|
+
|
|
520
|
+
if (!isWebGLAvailable()) showMyOwnBrowserTooOldScreen(); // ask BEFORE booting
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`isWebGLAvailable()` probes a throwaway canvas and never touches yours; it
|
|
524
|
+
returns `false` outside a browser.
|
|
@@ -41,8 +41,14 @@ const game = await createGame3D({
|
|
|
41
41
|
scene: sceneJson, // cloned internally — no cast, no structuredClone
|
|
42
42
|
pointer: true, // pointer-look + lock-on-click (FPS pattern); default off
|
|
43
43
|
// behaviors: { ... }, // registered for you (hot-replace tolerant)
|
|
44
|
+
// settings: { namespace: 'my-game' }, // ← if anything else Incanto is
|
|
45
|
+
// served from this domain. Volume, language and the quality tier persist
|
|
46
|
+
// under `incanto:<namespace>:`, and the default is shared by everyone: a
|
|
47
|
+
// neighbouring game's player can boot yours at quarter resolution, muted.
|
|
44
48
|
});
|
|
45
49
|
// game.engine / game.scene / game.renderer / game.physics
|
|
50
|
+
// `game.scene` is the LIVE scene, not the one you booted with — it follows every
|
|
51
|
+
// swap, including `flow.restart()`. `game.sourceJson` is the authored JSON.
|
|
46
52
|
// game.dispose() — ONE call tears everything down (SPA unmount)
|
|
47
53
|
```
|
|
48
54
|
|
|
@@ -629,3 +635,53 @@ or empty, and nothing throws.
|
|
|
629
635
|
headless bot sessions and reports what could not be reached, which wires never
|
|
630
636
|
fired, and whether anything in the level can hurt the player. See
|
|
631
637
|
`incanto-playtesting.md`.
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
### `findFloatingProps` throws rather than reporting a clean scene it never read
|
|
641
|
+
|
|
642
|
+
It answers "which props hover above the terrain", and it needs the scene to LOAD
|
|
643
|
+
and a `Terrain3D` to measure against. It used to return `[]` for both failures —
|
|
644
|
+
so a scene that would not load, a scene with no terrain and a genuinely clean
|
|
645
|
+
scene all printed the same green.
|
|
646
|
+
|
|
647
|
+
It stubs unregistered behaviors (it measures geometry, not logic; a scene with
|
|
648
|
+
any custom behavior used to read as clean — measured 0 floating before
|
|
649
|
+
`registerBehavior`, 26 after) and throws when it cannot measure. A level built
|
|
650
|
+
from mesh floors has no terrain height to read: use a physics raycast instead.
|
|
651
|
+
|
|
652
|
+
## When the boot FAILS: say so, or the bar sits at 100% forever
|
|
653
|
+
|
|
654
|
+
`createGame2D`/`createGame3D` reject like any promise, and the loading overlay
|
|
655
|
+
every starter shows is removed on the line *after* the await. So a boot that
|
|
656
|
+
fails leaves the bar at 100% with no text, and the reason in a console the
|
|
657
|
+
player will never open. Measured with WebGL denied on a pristine scaffold: the
|
|
658
|
+
overlay was still reading `Emberwood 100%` 8.6 seconds later.
|
|
659
|
+
|
|
660
|
+
One `.catch` fixes it, and every shipped template now carries it:
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
import { createGame3D, showBootFailure } from 'incanto/3d';
|
|
664
|
+
|
|
665
|
+
const game = await createGame3D({ canvas, scene: sceneJson }).catch((e) => {
|
|
666
|
+
showBootFailure(e); // paints the reason into #loading (or its own panel)
|
|
667
|
+
throw e;
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
document.querySelector('#loading')?.remove();
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
**No WebGL is the case worth naming.** A machine with acceleration off, a
|
|
674
|
+
blocklisted driver, a locked-down or headless browser, or a page that already
|
|
675
|
+
holds too many contexts cannot make one, and three throws
|
|
676
|
+
`Error('Error creating WebGL context.')` with nothing to branch on. The engine
|
|
677
|
+
rethrows it as an `IncantoError` with code **`WEBGL_UNAVAILABLE`** and a message
|
|
678
|
+
naming the likely causes, so you can show a real screen:
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
import { isWebGLAvailable } from 'incanto/3d';
|
|
682
|
+
|
|
683
|
+
if (!isWebGLAvailable()) showMyOwnBrowserTooOldScreen(); // ask BEFORE booting
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
`isWebGLAvailable()` probes a throwaway canvas and never touches yours; it
|
|
687
|
+
returns `false` outside a browser.
|
package/skills/incanto-editor.md
CHANGED
|
@@ -44,7 +44,11 @@ The `scenes` button opens the project as a tree, not a list of paths:
|
|
|
44
44
|
panel — so `⏎` loads something without arrowing first.
|
|
45
45
|
- Each row carries **when it was last written and how big it is**, which is usually
|
|
46
46
|
how you recognise the file you were just in.
|
|
47
|
-
- **create** makes a new scene at the path in the box —
|
|
47
|
+
- **create** makes a new scene at the path in the box — **matching the project**:
|
|
48
|
+
a 3D game gets `dimension: "3d"` with a `Node3D` root, a 2D game gets 2D, read
|
|
49
|
+
from the scenes already on disk. It used to hardcode 2D for everyone, and node
|
|
50
|
+
types are immutable by design, so a 3D author's new scene could only be deleted
|
|
51
|
+
and re-made by hand — left empty it uses the
|
|
48
52
|
placeholder, which tracks the folder you are standing in, so a scene lands beside
|
|
49
53
|
its siblings rather than at the project root. Parent dirs are created.
|
|
50
54
|
- Loading another scene while you have unsaved EDITS asks first, and the answer
|
|
@@ -799,7 +799,7 @@ hue reads grey.
|
|
|
799
799
|
| `sunDirection` | `[0.5, 0.8, 0.3]` | TOWARD the sun, for the specular glint — match the scene's key light (non-zero, load-time check) |
|
|
800
800
|
| `sunColor` | `"#fff5d6"` | glint/sheen tint |
|
|
801
801
|
| `sunIntensity` | `1` | glint/sheen strength (≥ 0; 0 = off) |
|
|
802
|
-
| `detailStrength` | `0.
|
|
802
|
+
| `detailStrength` | `0.26` | animated detail-normal strength (≥ 0; 0 = vertex normals only) |
|
|
803
803
|
| `absorption` | `0.15` | Beer's-law constant per meter of water depth (≥ 0; lower = clearer/wider turquoise band; red absorbs ~3× faster than blue under the hood) |
|
|
804
804
|
| `refraction` | `true` | screen-space refraction of the submerged scene |
|
|
805
805
|
| `preset` | `"custom"` | **THE one-choice water type** (0.16.0): `ocean` \| `pool` \| `lake` \| `pond` bundles every wave/color/clarity/caustics knob into a hand-tuned sea state. A preset value applies ONLY where a prop still sits at its schema default — pick the type, then override any detail prop and your value wins. ocean = traveling swell + whitecaps + wide turquoise→deep absorption · pool = near-flat crystal water + strong above-water caustics · lake = calm high-reflectivity mirror, green-teal murk · pond = still, mossy, opaque quickly. `custom` (default) changes nothing |
|
|
@@ -1074,7 +1074,7 @@ underneath decides everything else.
|
|
|
1074
1074
|
| `opacity` | `0.85` | upper bound on the body's opacity |
|
|
1075
1075
|
| `foam` | `1` | whitewater dial — 0 = a glassy canal, 2 = raging |
|
|
1076
1076
|
| `ripples` | `1` | surface-detail dial (ripple relief + glitter) |
|
|
1077
|
-
| `terrain` | `
|
|
1077
|
+
| `terrain` | `""` | drape target path; empty = auto-find the first Terrain3D (a wrong path fails at load) |
|
|
1078
1078
|
| `carve` | `true` | cut the bed. The river trenches its own channel into the terrain it drapes on — see below |
|
|
1079
1079
|
| `flowForce` | `1` | how hard the current sweeps bodies downstream (0 = visual only) |
|
|
1080
1080
|
| `spray` | `1` | mist at the foot of every drop the course turns out to contain — the node finds them and hangs a Particles3D plume on each (0 = off) |
|
|
@@ -111,7 +111,7 @@ what breaks one that forgets the other half:
|
|
|
111
111
|
|
|
112
112
|
```json
|
|
113
113
|
{ "signal": "died", "from": "Player", "to": "Score", "handler": "loseLife" },
|
|
114
|
-
{ "signal": "lifeLost", "from": "Score", "to": "Player", "handler": "
|
|
114
|
+
{ "signal": "lifeLost", "from": "Score", "to": "Player", "handler": "reviveFull" }
|
|
115
115
|
```
|
|
116
116
|
|
|
117
117
|
Without the second wire the player is a walking corpse after the first death —
|
|
@@ -1211,7 +1211,7 @@ spawners, music, or monster aggression to it. The `hour` field (0-24) is
|
|
|
1211
1211
|
writable: jump to a time of day in one assignment. For one-off look changes
|
|
1212
1212
|
use `setEnvironment3D(engine, patch)` from `incanto/3d` directly.
|
|
1213
1213
|
|
|
1214
|
-
## Boot loading overlay
|
|
1214
|
+
## Boot loading overlay
|
|
1215
1215
|
|
|
1216
1216
|
```ts
|
|
1217
1217
|
import { preloadSceneAssets } from 'incanto';
|
|
@@ -1221,6 +1221,33 @@ await preloadSceneAssets(sceneJson.assets); // black screen + bar + %, auto-re
|
|
|
1221
1221
|
Replaces the ~125-line hand-rolled loading screen; `createLoadingOverlay()`
|
|
1222
1222
|
gives you the raw overlay when you preload something else.
|
|
1223
1223
|
|
|
1224
|
+
## Where a node actually IS (`position` is parent-relative)
|
|
1225
|
+
|
|
1226
|
+
`node.position` is the offset from its PARENT, not the world. A scene nests
|
|
1227
|
+
freely — `Terrain/Clouds/Cloud1/Puff1` four deep is what the engine's own
|
|
1228
|
+
generator writes — so a distance check reading `position` on a nested node
|
|
1229
|
+
measures from the wrong origin and says nothing about it. Measured on a crate
|
|
1230
|
+
parented to a rock: `position` is `[0, 3.02, 0]` and the world point is
|
|
1231
|
+
`[13, 14.31, 80.18]` — **82 m apart**.
|
|
1232
|
+
|
|
1233
|
+
```ts
|
|
1234
|
+
import { localFromWorld, worldPosition } from 'incanto/gameplay';
|
|
1235
|
+
|
|
1236
|
+
const here = worldPosition(crate); // [13, 14.31, 80.18]
|
|
1237
|
+
crate.position = localFromWorld(crate, [0, 20, 0]); // put it at world [0,20,0]
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
Both work headless and in both dimensions — arity comes from the node (2 for a
|
|
1241
|
+
2D node, 3 for a 3D one), and an ancestor with no `position` contributes
|
|
1242
|
+
nothing. Do NOT reach for `_ensureObject3D().matrixWorld`: it is internal, and
|
|
1243
|
+
in a headless verify script there is no renderer to parent the object, so it
|
|
1244
|
+
hands back the LOCAL position with no warning.
|
|
1245
|
+
|
|
1246
|
+
**Translation only.** They sum ancestor positions and ignore ancestor rotation
|
|
1247
|
+
and scale — the same contract physics already uses (`worldPosition2D` /
|
|
1248
|
+
`worldPosition3D`) and that `applyGroundSnap` assumes. A node under a ROTATING
|
|
1249
|
+
parent needs the renderer's matrix, not this.
|
|
1250
|
+
|
|
1224
1251
|
## Persistence (high scores, unlocks, settings)
|
|
1225
1252
|
|
|
1226
1253
|
```ts
|
package/skills/incanto-hud.md
CHANGED
|
@@ -183,7 +183,20 @@ support" was not something a JSON scene could express at all.
|
|
|
183
183
|
```
|
|
184
184
|
|
|
185
185
|
Arrow keys / d-pad move the focus between the **focusable** widgets under that
|
|
186
|
-
layer
|
|
186
|
+
layer — and only the ones the player can actually SEE: a hidden widget hides
|
|
187
|
+
everything under it, and a hidden layer has no ring at all. (Until 0.67 the walk
|
|
188
|
+
recursed into closed panels, so Enter on a title screen could press a button in
|
|
189
|
+
the shop and spend the gold.) `hud.focusables()` returns the ring, so a game can
|
|
190
|
+
ask what it is stuck with.
|
|
191
|
+
|
|
192
|
+
A **`UiDialogue` takes focus while it is up**: Enter/A picks the highlighted
|
|
193
|
+
choice, left/right move between them, and a line with no choices advances. Its
|
|
194
|
+
choices are DOM buttons inside the widget rather than nodes, so this is the
|
|
195
|
+
widget's own key handling — before 0.67 a choice could only be answered with a
|
|
196
|
+
mouse, and `charsPerSecond: 0` rendered no buttons at all, which was an
|
|
197
|
+
unanswerable soft-lock.
|
|
198
|
+
|
|
199
|
+
`Enter` / `A` activates, and the focused one wears a ring.
|
|
187
200
|
`UiButton`/`UiSlider`/`UiToggle`/`UiSelect` are focusable by default;
|
|
188
201
|
`UiText`/`UiBar`/`UiImage`/`UiPanel` are not, so arrowing never lands on a label.
|
|
189
202
|
|
|
@@ -378,3 +378,17 @@ Kernel constraints (agent8 platform facts — don't fight them):
|
|
|
378
378
|
client transport swap (for a normal single-client game, just omit `{transport}`;
|
|
379
379
|
a split-screen demo like arena-preview also drops its local-only scaffolding) PLUS
|
|
380
380
|
deploying `server/` (the "Going live on agent8" section above) — not literally one line.
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
## The `agrees` rung runs YOUR server
|
|
384
|
+
|
|
385
|
+
`incanto-multiplay` loads `server/src/server.ts` (or `--server FILE`) and runs
|
|
386
|
+
the match against it. It did not until 0.67: every match ran on a bare loopback
|
|
387
|
+
kernel with no remote functions, so `roomState` came back `{}` instead of
|
|
388
|
+
`{"matchMs": 5983.3}`, a `manager.call('claimRelic')` threw
|
|
389
|
+
`Loopback kernel has no remote function 'claimRelic'`, and the report still said
|
|
390
|
+
`ok=true errors=0 problems=[]`. Moving `server/src/server.ts` out of the tree
|
|
391
|
+
changed nothing — a ✓ that could not have been a ✗.
|
|
392
|
+
|
|
393
|
+
If your server cannot be loaded the run REFUSES rather than quietly measuring a
|
|
394
|
+
game without its own rules.
|
|
@@ -297,6 +297,7 @@ Signals: `movementStateChanged(state)`
|
|
|
297
297
|
| `pitchMin` | `-1.3` | number |
|
|
298
298
|
| `pitchMax` | `1.5` | number |
|
|
299
299
|
| `cameraCollision` | `true` | boolean |
|
|
300
|
+
| `platformCarry` | `true` | boolean |
|
|
300
301
|
| `turnSpeed` | `100` | number |
|
|
301
302
|
| `camLerp` | `25` | number |
|
|
302
303
|
| `moveAction` | `"move"` | string |
|
|
@@ -1039,7 +1040,7 @@ Signals: `dragStarted` · `dragCancelled` · `droppedOn` · `dropped` · `presse
|
|
|
1039
1040
|
|---|---|---|
|
|
1040
1041
|
| `anchor` | `"bottom"` | one of: `topLeft` `top` `topRight` `left` `center` `right` `bottomLeft` `bottom` `bottomRight` |
|
|
1041
1042
|
| `visible` | `true` | boolean |
|
|
1042
|
-
| `focusable` | `
|
|
1043
|
+
| `focusable` | `true` | boolean |
|
|
1043
1044
|
| `draggable` | `false` | boolean |
|
|
1044
1045
|
| `dropTarget` | `false` | boolean |
|
|
1045
1046
|
| `charsPerSecond` | `40` | number |
|
|
@@ -289,3 +289,38 @@ Set `maxFps` to 30 and confirm `fps` settles near 30 — if it settles near 20,
|
|
|
289
289
|
is slower than the cap and the cap is not what is limiting it.
|
|
290
290
|
|
|
291
291
|
See `incanto-verifying-your-game.md` for the full loop.
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
## Identical meshes share their GPU objects
|
|
295
|
+
|
|
296
|
+
`MeshInstance3D` used to make a private geometry AND material per node, even
|
|
297
|
+
when byte-identical. Measured at 3,200 units — all runs at the same 3,209 draw
|
|
298
|
+
calls, 1,741,932 triangles and an identical screenshot:
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
per-node geometry + material 31.0 ms wall (renderMs 27.3)
|
|
302
|
+
share one geometry 27.0 ms
|
|
303
|
+
+ share two materials 15.4 ms <- 2x the headroom
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
13.1 ms of that cliff was GPU state churn from per-node objects. Nodes now share
|
|
307
|
+
one geometry per (mesh, size) and one material per distinct `material` dict, and
|
|
308
|
+
a node whose look changes gets its own again. **Nothing to do — it is the
|
|
309
|
+
default.** The consequence to know: mutate node PROPS, never the three.js
|
|
310
|
+
material you fished out of a node, because it may not be only yours.
|
|
311
|
+
|
|
312
|
+
This does not replace `InstancedMesh3D`. A field of identical props is still one
|
|
313
|
+
node and one draw call there (12,800 units, 11 draw calls, 2.30 ms); sharing
|
|
314
|
+
helps the one-node-per-thing path, which is what a hand-authored level is.
|
|
315
|
+
|
|
316
|
+
## The quality tier only ever takes away
|
|
317
|
+
|
|
318
|
+
`high` no longer mentions `bloom` or `post` at all, because mentioning them
|
|
319
|
+
CREATED them: on a scene that declared neither, `high` and `medium` both
|
|
320
|
+
rendered a five-pass frame at 3.3 ms where `low` rendered one pass at 1.24 ms —
|
|
321
|
+
62% of the frame on a chain the game never asked for, and
|
|
322
|
+
`engine.scene.environment.bloom` read back `{strength: 0.8, threshold: 1}`.
|
|
323
|
+
|
|
324
|
+
A tier is also REVERSIBLE now: it is applied against the scene FILE rather than
|
|
325
|
+
against whatever the last tier left behind, so `low` → `high` restores exactly
|
|
326
|
+
what you authored instead of leaving it stripped.
|
|
@@ -479,6 +479,29 @@ rotating discs. It is what makes a floating-island game work.
|
|
|
479
479
|
Author it the obvious way: move the platform's `position` (a behavior, a
|
|
480
480
|
`PathFollow`, an `Oscillate`). Nothing else to declare.
|
|
481
481
|
|
|
482
|
+
**The 3D PLAYER is a different rig, and it needs one prop.** A
|
|
483
|
+
`CharacterController3D` must sit under a dynamic `RigidBody3D` (hard error
|
|
484
|
+
otherwise), not a `CharacterBody3D` — the character bodies in a 3D game are its
|
|
485
|
+
enemies and NPCs. The controller steers toward a target velocity every fixed
|
|
486
|
+
step, so pressing nothing means "target zero", which in world space is a brake
|
|
487
|
+
aimed at the platform's own motion. `platformCarry` (default **true**) makes
|
|
488
|
+
that target relative to the floor instead. Measured on a 400 m deck moving
|
|
489
|
+
4 m/s, rider pressing nothing: **95.9%** of the travel kept, **0.0%** with
|
|
490
|
+
`platformCarry: false`.
|
|
491
|
+
|
|
492
|
+
```json
|
|
493
|
+
{ "name": "Ctl", "type": "CharacterController3D",
|
|
494
|
+
"props": { "view": "free", "platformCarry": false } }
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Turn it off for a conveyor you want to be scenery. Vertical carry needs no prop
|
|
498
|
+
either way — the hover spring rides whatever surface is under it, which is why
|
|
499
|
+
a 3D elevator always looked right while a conveyor did not.
|
|
500
|
+
|
|
501
|
+
The carry comes from the platform's POSITION DELTA, not its `linearVelocity`,
|
|
502
|
+
because writing `position` calls `setTranslation` — a teleport that transfers no
|
|
503
|
+
momentum to anything. Both authoring styles work.
|
|
504
|
+
|
|
482
505
|
The carry stops the moment the character is no longer grounded on it, so walking
|
|
483
506
|
off an edge or jumping is not "sticky", and it is vertical as well as horizontal
|
|
484
507
|
— an elevator lifts you.
|
|
@@ -51,10 +51,24 @@ 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
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 |
|
|
55
56
|
| `never reached` | destinations the bot never came within `--reach-radius` of (default 2 m in 3D, 32 px in 2D) |
|
|
56
57
|
| `never fired` | signals a `connections[]` entry listens to that never happened |
|
|
57
|
-
| `danger` | how many times the player's `Health` emitted `damaged` |
|
|
58
|
+
| `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
|
+
**The bot can use the MOUSE.** If your scene has `Clickable` nodes, it presses
|
|
61
|
+
and releases over them at random — through the real `Clickable`, the real
|
|
62
|
+
`mouseJustPressed` edge and your real connection, with a picker that answers
|
|
63
|
+
from the tree (the recipe under "Testing a mouse-driven game headlessly"). It
|
|
64
|
+
drove actions and nothing else before, so a tactics game, a tower defense and a
|
|
65
|
+
duel all reported the identical `? plays — 8 runs played without reaching a
|
|
66
|
+
win`. A scene with nothing clickable is untouched: it does not gain an
|
|
67
|
+
`engine.picker` it never had.
|
|
68
|
+
|
|
69
|
+
A random clicker still cannot finish a game that needs a SEQUENCE — select a
|
|
70
|
+
trooper, then a tile, then end the turn — and that is reported as unmeasured,
|
|
71
|
+
not failed.
|
|
58
72
|
|
|
59
73
|
**Nothing here is a guess about your design.** The scene already declares what
|
|
60
74
|
winning is; this only listens.
|
|
@@ -89,7 +89,27 @@ game that shipped six weeks ago. Check what you read.
|
|
|
89
89
|
Built-ins that already save: `Health` (current, dead), `ScoreKeeper` (score,
|
|
90
90
|
lives, won/lost), `Collector` (total).
|
|
91
91
|
|
|
92
|
-
## Every node you save needs a uid
|
|
92
|
+
## Every node you save — and every node that VANISHES — needs a uid
|
|
93
|
+
|
|
94
|
+
A collectible does not save state; it DISAPPEARS, and disappearing is the thing
|
|
95
|
+
the save has to record. `#freed` is keyed by uid, so a pickup without one is
|
|
96
|
+
silently omitted: the score that counted it restores, and the pickup restores
|
|
97
|
+
too. Measured on a starter with 1 uid across 141 nodes:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
coins after collecting : 9
|
|
101
|
+
save["#freed"] : undefined
|
|
102
|
+
restore report : { restored: 1, expected: 1, freed: 0 }
|
|
103
|
+
coins AFTER load : 12 <- three coins resurrected
|
|
104
|
+
auditScene warnings : [] <- and every checker was green
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`captureState` now reports this as an ERROR naming the nodes, because the save
|
|
108
|
+
it just wrote is already wrong. It does not guess a replacement: a uid survives
|
|
109
|
+
a rename and a reparent, and a saved PATH would point at whatever node moved
|
|
110
|
+
into that slot after your next edit.
|
|
111
|
+
|
|
112
|
+
|
|
93
113
|
|
|
94
114
|
The uid is the join key, because it is the one identifier that survives a rename
|
|
95
115
|
or a reparent. The editor assigns one to every node it touches. A hand-written
|
|
@@ -26,6 +26,7 @@ wiring, and composition are all in the file. TS is for *behavior only* (attached
|
|
|
26
26
|
"constants": { "UI": 1000, "Background": -100 },
|
|
27
27
|
"input": { "<action>": { "...": "...", "touch": "joystick|button (optional — mobile on-screen controls)" } },
|
|
28
28
|
"multiplayer": { "room": "auto" },
|
|
29
|
+
"fragment": false,
|
|
29
30
|
"root": { "name": "Level1", "type": "Node", "children": [] },
|
|
30
31
|
"connections": []
|
|
31
32
|
}
|
|
@@ -33,6 +34,13 @@ wiring, and composition are all in the file. TS is for *behavior only* (attached
|
|
|
33
34
|
|
|
34
35
|
- `format` MUST be `1`. `type` MUST be `"scene"`. `name` non-empty.
|
|
35
36
|
- `dimension` is optional: `"2d"` or `"3d"` only.
|
|
37
|
+
- `fragment` (optional, default `false`) says this file is a PIECE of another
|
|
38
|
+
scene — a spawner's prefab, a sub-scene something instances — and is never
|
|
39
|
+
rendered on its own. It changes nothing at runtime; it tells the checker to
|
|
40
|
+
stop asking a prefab where its camera and its sun are. Set it on any scene
|
|
41
|
+
you load from TypeScript and hand to a spawner. (A scene another scene
|
|
42
|
+
embeds with `instance` needs no key — `incanto-check` walks the project and
|
|
43
|
+
can see that for itself.)
|
|
36
44
|
- `viewport` (optional) makes scene JSON own responsive layout: author the world
|
|
37
45
|
in fixed `design` pixels (`[width, height]`, positive numbers) and the renderer
|
|
38
46
|
maps them onto any canvas size. `fit`: `"expand"` (design rect always fully
|
|
@@ -158,7 +166,10 @@ level 1's layers.
|
|
|
158
166
|
## Parents and children
|
|
159
167
|
|
|
160
168
|
EVERY node type can hold children — `children` is universal, so any node works
|
|
161
|
-
as a grouping container.
|
|
169
|
+
as a grouping container. **A child's `position` is relative to its parent**, so
|
|
170
|
+
nesting is also how you move a group: shift the parent and everything under it
|
|
171
|
+
follows. From code, `worldPosition(node)` (`incanto/gameplay`) is where a nested
|
|
172
|
+
node actually is; `node.position` is the local offset. The inverse is not true: some types demand a SPECIFIC
|
|
162
173
|
parent and are therefore also invalid as the root. Today's rule:
|
|
163
174
|
|
|
164
175
|
- `CharacterController2D` must be a direct child of a `CharacterBody2D` —
|
|
@@ -858,3 +858,22 @@ const inputToWorld = Math.atan2(at()[1] - before[1], at()[0] - before[0]);
|
|
|
858
858
|
|
|
859
859
|
`examples/village-quest-3d/verify.ts` does exactly this and fails with
|
|
860
860
|
"the player cannot reach it on foot" when it cannot.
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
## When node refuses your TypeScript
|
|
864
|
+
|
|
865
|
+
The bins' shebang is node, and node's type stripping **erases** types — it does
|
|
866
|
+
not transform. Three ordinary things it refuses, all of which vite and bun
|
|
867
|
+
compile without comment:
|
|
868
|
+
|
|
869
|
+
| what you wrote | what node says |
|
|
870
|
+
|---|---|
|
|
871
|
+
| `import scene from './game.scene.json'` | needs an import attribute of "type: json" |
|
|
872
|
+
| `import { quest } from './quest'` | Cannot find module …/quest |
|
|
873
|
+
| `constructor(private root: Node3D)` | TypeScript parameter property is not supported in strip-only mode |
|
|
874
|
+
|
|
875
|
+
**You do not have to change any of them.** Every bin that loads a `--behaviors`
|
|
876
|
+
file re-runs itself under bun when node refuses it, so the command just works.
|
|
877
|
+
If bun is not on PATH the message says which of the three it hit.
|
|
878
|
+
|
|
879
|
+
Prefix with `bunx --bun` to skip the round trip.
|
|
@@ -83,13 +83,13 @@ and it is entirely JSON.
|
|
|
83
83
|
```jsonc
|
|
84
84
|
"connections": [
|
|
85
85
|
{ "signal": "died", "from": "Player", "to": "Score", "handler": "loseLife" },
|
|
86
|
-
{ "signal": "lifeLost", "from": "Score", "to": "Player", "handler": "
|
|
86
|
+
{ "signal": "lifeLost", "from": "Score", "to": "Player", "handler": "reviveFull" },
|
|
87
87
|
{ "signal": "won", "from": "Score", "to": "Flow", "handler": "win" },
|
|
88
88
|
{ "signal": "lost", "from": "Score", "to": "Flow", "handler": "gameOver" }
|
|
89
89
|
]
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
**`lifeLost →
|
|
92
|
+
**`lifeLost → reviveFull` is not optional.** A `Health` that has died stays dead:
|
|
93
93
|
`damage`, `heal` and regen all stop, so without that wire the player becomes a
|
|
94
94
|
walking corpse after the first death — full HP bar, immune to everything,
|
|
95
95
|
`died` never firing again, lives frozen, and the game quietly unlosable. It
|
|
@@ -158,7 +158,7 @@ floats over small ledges already.
|
|
|
158
158
|
The tell for both is one line of `incanto-playtest` output:
|
|
159
159
|
|
|
160
160
|
```
|
|
161
|
-
danger:
|
|
161
|
+
danger: nothing in this scene can hurt the player — no DamageOnContact, no hazard group
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
Read it. It is the truth.
|
|
@@ -219,7 +219,7 @@ Things that cost real time, in the order you will meet them.
|
|
|
219
219
|
|
|
220
220
|
| when | the trap |
|
|
221
221
|
| --- | --- |
|
|
222
|
-
| a game with LIVES | `died` stops a `Health` for good — wire `lifeLost →
|
|
222
|
+
| a game with LIVES | `died` stops a `Health` for good — wire `lifeLost → reviveFull` or you can spend only one. **Not `revive`**: `lifeLost` carries the life COUNT and `revive(hp?)` reads it as health, so you come back at 2 HP, then 1, then 0. |
|
|
223
223
|
| wiring a score | `died` carries nothing; `addScore(n)` wants one → `NaN`. Wire `dealtDamage` from the killer. |
|
|
224
224
|
| enemies feel harmless | `repeatEvery` on the contact hitbox, or one hit is all you get. |
|
|
225
225
|
| enemies never arrive | a chaser cannot climb — `stepHeight`, and it loses to a large downward velocity you apply yourself. |
|