incanto 0.68.0 → 0.69.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/assets/catalog.json +9 -5
- package/bin/incanto-assets.mjs +14 -1
- package/bin/incanto-check.mjs +53 -3
- package/bin/incanto-new.mjs +29 -1
- package/bin/incanto-serve.mjs +160 -0
- package/bin/incanto-verify.mjs +40 -0
- package/bin/incanto.mjs +1 -0
- package/dist/2d.d.ts +7 -5
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +4 -4
- package/dist/3d.js +7 -7
- package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-C0j2k-yB.d.ts} +32 -13
- package/dist/{behavior-DoFPYrgo.d.ts → behavior-rm8vVzu_.d.ts} +67 -0
- package/dist/{create-game-DbWtVTxD.js → create-game-CvJ1wSRP.js} +13 -9
- package/dist/{create-game-IZIydDwI.js → create-game-CwxEDlCm.js} +13 -9
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-IWIqk0HJ.js → duplicate-Dj4L-5AA.js} +1 -1
- package/dist/editor.js +1 -1
- package/dist/{environment-presets-CybQXNqS.js → environment-presets-BO-7XoFV.js} +4 -4
- package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
- package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
- package/dist/{gameplay-DM1eu_cV.js → gameplay-D1KixWK8.js} +157 -151
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +6 -6
- package/dist/{loader-BC4PNtJX.js → loader-DZE7B07H.js} +22 -6
- package/dist/{loader-CcB533FR.d.ts → loader-DadV2QCT.d.ts} +1 -1
- package/dist/net.d.ts +2 -2
- package/dist/net.js +1 -1
- package/dist/{physics-2d-B7Y6dPZO.js → physics-2d-CF5yyjbj.js} +2 -2
- package/dist/{physics-3d-bG3n70Ky.js → physics-3d-DbYaEcZy.js} +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-ibCjm-wH.js → register-DbVArsns.js} +19 -10
- package/dist/{register-3ta-2Xig.js → register-mmfugpaQ.js} +107 -15
- package/dist/{replay-CYvhVHHN.js → replay-BEHLDcQV.js} +2 -2
- package/dist/{replay-Dvn8aeBd.d.ts → replay-BQfkX4x7.d.ts} +1 -1
- package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
- package/dist/{split-screen-CYwDkbLF.js → split-screen-CauMwu6y.js} +2 -2
- package/dist/{split-screen-DhrSzZIB.d.ts → split-screen-Cg76MCV5.d.ts} +2 -2
- package/dist/{src-D7RIqXYF.js → src-CYEu-_S9.js} +1 -1
- package/dist/{teardown-D2NEmxPB.js → teardown-CMqFVxO9.js} +5 -4
- package/dist/{test-8hoHeRmo.js → test-D6ISmuFE.js} +60 -17
- package/dist/test.d.ts +15 -4
- package/dist/test.js +2 -2
- package/dist/vite.d.ts +70 -2
- package/dist/vite.js +176 -14
- package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-Z5_g8x9H.js} +12 -1
- package/editor/assets/{agent8-BrrHOjMJ.js → agent8-BDvaZMQJ.js} +1 -1
- package/editor/assets/{debug-BBhKuBNV.js → debug-CMULinBW.js} +1 -1
- package/editor/assets/{index-eVd0BToA.js → index-CZIRr5EL.js} +91 -91
- package/editor/index.html +1 -1
- package/package.json +4 -3
- package/skills/incanto-assets.md +16 -2
- package/skills/incanto-audio.md +19 -3
- package/skills/incanto-building-2d-games.md +29 -0
- package/skills/incanto-building-3d-games.md +60 -2
- package/skills/incanto-gameplay-behaviors.md +18 -1
- package/skills/incanto-save-slots.md +27 -0
- package/skills/incanto-verifying-your-game.md +96 -2
- package/skills/incanto-your-first-game.md +2 -1
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/main.ts +13 -3
- package/templates-app/platformer-2d/index.html +0 -23
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/platformer-2d/src/behaviors.ts +26 -16
- package/templates-app/platformer-2d/src/game.scene.json +17 -27
- package/templates-app/platformer-2d/src/main.ts +24 -12
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/star-survivor/src/main.ts +17 -6
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/main.ts +26 -15
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/main.ts +13 -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-CZIRr5EL.js"></script>
|
|
9
9
|
<link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "incanto",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.0",
|
|
4
4
|
"description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"game-engine",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "tsdown",
|
|
56
56
|
"dev": "tsdown --watch",
|
|
57
|
-
"typecheck": "tsc --noEmit",
|
|
57
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
|
58
58
|
"test": "vitest run --project unit --root ../.."
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"incanto-new": "bin/incanto-new.mjs",
|
|
103
103
|
"incanto-frame": "bin/incanto-frame.mjs",
|
|
104
104
|
"incanto-verify": "./bin/incanto-verify.mjs",
|
|
105
|
-
"incanto-logs": "./bin/incanto-logs.mjs"
|
|
105
|
+
"incanto-logs": "./bin/incanto-logs.mjs",
|
|
106
|
+
"incanto-serve": "./bin/incanto-serve.mjs"
|
|
106
107
|
}
|
|
107
108
|
}
|
package/skills/incanto-assets.md
CHANGED
|
@@ -21,13 +21,27 @@ playable character — model, locomotion clips, body, controller, skin and input
|
|
|
21
21
|
## 1. Built-in assets (in the package)
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
bunx incanto-assets list #
|
|
24
|
+
bunx incanto-assets list # name · kind · GRID · what it is
|
|
25
25
|
bunx incanto-assets info medieval-knight # description + animation names
|
|
26
26
|
bunx incanto-assets copy medieval-knight --out public/assets
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
`list --json` prints every entry. Each entry carries a **`url`** — the drop-in
|
|
30
|
-
reference you put in scene JSON so the asset LOADS (the contract, see §1b)
|
|
30
|
+
reference you put in scene JSON so the asset LOADS (the contract, see §1b) —
|
|
31
|
+
and, where the art has a grid, the numbers a node needs:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
2dbasic character [animated] 111×83 2dbasic sprite sheet image…
|
|
35
|
+
minecraft-tiles tile 16×16 (25 tiles) Minecraft-themed tiles…
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`frameWidth`/`frameHeight` are what `AnimatedSprite2D.frameWidth` and
|
|
39
|
+
`TileMap2D.tileSize` want; `columns` and `tiles` tell you the highest index a
|
|
40
|
+
`legend` may name (25 tiles means 0–24, and asking for 99 draws clamp-streaks —
|
|
41
|
+
the engine reports it, but the catalog is where you get the number). **The sizes
|
|
42
|
+
live in these fields and nowhere else** — a frame size in the description was a
|
|
43
|
+
second copy, and it drifted: `2dbasic` said 192×192 for art whose real grid is
|
|
44
|
+
111×83.
|
|
31
45
|
|
|
32
46
|
### Categories — the whole built-in set
|
|
33
47
|
|
package/skills/incanto-audio.md
CHANGED
|
@@ -414,7 +414,7 @@ let cursor = 0; // next un-queued note
|
|
|
414
414
|
engine.updated.connect(() => {
|
|
415
415
|
const horizon = engine.sfx.now + LEAD;
|
|
416
416
|
while (cursor < chart.length && startedAt + chart[cursor].atSec <= horizon) {
|
|
417
|
-
hit.
|
|
417
|
+
hit.playAt(startedAt + chart[cursor].atSec); // NOT play() — see below
|
|
418
418
|
cursor++;
|
|
419
419
|
}
|
|
420
420
|
});
|
|
@@ -422,9 +422,14 @@ engine.updated.connect(() => {
|
|
|
422
422
|
|
|
423
423
|
- **`engine.sfx.now`** — seconds on the audio clock, the one the sound is
|
|
424
424
|
actually placed on. `0` headless and before the first sound.
|
|
425
|
-
- **`AudioPlayer.
|
|
425
|
+
- **`AudioPlayer.playAt(when)`** and **`engine.sfx.play(params, gain, { when })`** —
|
|
426
426
|
PRESETS only. A `src` clip goes through an `<audio>` element, which has no
|
|
427
|
-
scheduling clock
|
|
427
|
+
scheduling clock, so `playAt` on one plays immediately.
|
|
428
|
+
- **`play()` takes no arguments, deliberately.** It is the method scenes wire
|
|
429
|
+
signals to, and a signal hands its handler whatever it carries — `collected`
|
|
430
|
+
leads with a number, so `collected → play` would have become `play(10)` and
|
|
431
|
+
scheduled the pickup sound at absolute audio-clock second 10. Scheduling has
|
|
432
|
+
its own name.
|
|
428
433
|
- A time already past plays immediately (Web Audio's own rule), so a scheduler
|
|
429
434
|
that ran late is late, not silent.
|
|
430
435
|
- Everything else still applies: the bus gain, `engine.audio.recent()`, and
|
|
@@ -479,6 +484,17 @@ session.engine.audio.recent();
|
|
|
479
484
|
its volume comes from. `countOf(name)` is the assertion you usually want;
|
|
480
485
|
`clearLog()` resets between steps.
|
|
481
486
|
|
|
487
|
+
**A LOOPING sound is recorded once, and its entry carries `loop: true`.**
|
|
488
|
+
Re-recording every pass would evict the rest of the log within seconds for a
|
|
489
|
+
0.2 s preset, so the count stays 1 — which used to make "the alarm loops"
|
|
490
|
+
indistinguishable from "the alarm fired once and stopped". Assert the flag when
|
|
491
|
+
that is the difference you care about:
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
const alarm = session.engine.audio.recent().find((e) => e.name === 'alarm');
|
|
495
|
+
expect(alarm?.loop).toBe(true);
|
|
496
|
+
```
|
|
497
|
+
|
|
482
498
|
This covers every path: `AudioPlayer.play()` on both the procedural and the
|
|
483
499
|
`src` route, `engine.music.play`/`crossfadeTo`, and `engine.sfx.startVoice`. It
|
|
484
500
|
records the INTENT to play — that the wiring fired — not that a speaker moved;
|
|
@@ -212,6 +212,35 @@ offsets that survive any canvas size: a `Label` at `[-16, 16]` under a
|
|
|
212
212
|
`top-right` layer hugs the corner everywhere. Misspelled anchors fail at load
|
|
213
213
|
listing the valid set. With a viewport design, UI coordinates are design px.
|
|
214
214
|
|
|
215
|
+
### A `UILayer` still SCALES. On a phone, use `HudLayer`
|
|
216
|
+
|
|
217
|
+
`UILayer` ignores the camera; it does not ignore the viewport. Its contents are
|
|
218
|
+
design pixels multiplied by the same scale as the world, and on a portrait phone
|
|
219
|
+
that scale is small:
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
1280×800 scale 1.333 a fontSize 15 Label paints at 20 device px
|
|
223
|
+
390×844 scale 0.406 the same Label paints at 6.1 device px
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Six device pixels is an unreadable smear, and it is the flagship 2D template's
|
|
227
|
+
control hint before this was fixed. The vertical framing goes the same way:
|
|
228
|
+
`design: [960, 540]` under `fit: "expand"` shows a portrait phone **2077 world
|
|
229
|
+
px** of height, so the play surface is a band with acres of empty sky.
|
|
230
|
+
|
|
231
|
+
**`HudLayer` and the `Ui*` widgets are DOM at their declared CSS pixels** and are
|
|
232
|
+
immune — which is why the engine's own volume sliders and touch controls stayed
|
|
233
|
+
legible in the same capture that reduced the template's HUD to a grey line. Use
|
|
234
|
+
them for anything a player has to READ:
|
|
235
|
+
|
|
236
|
+
| | |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| `Label` under `UILayer` | part of the picture — damage numbers, world-anchored callouts, art |
|
|
239
|
+
| `UiText` under `HudLayer` | part of the interface — score, hearts, hints, menus |
|
|
240
|
+
|
|
241
|
+
See `incanto-hud.md`. It costs nothing on desktop and it is the difference
|
|
242
|
+
between a readable and an unusable phone build.
|
|
243
|
+
|
|
215
244
|
## Patterns
|
|
216
245
|
|
|
217
246
|
- Group pickups/enemies (`"groups": ["coins"]`) and query `scene.tree.getNodesInGroup('coins')`.
|
|
@@ -320,8 +320,66 @@ atmosphere recipe below).
|
|
|
320
320
|
|
|
321
321
|
## Reference example
|
|
322
322
|
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
The smallest 3D scene that renders something you can see. Every line matters:
|
|
324
|
+
without the light it is black, without `current: true` the camera is a guess,
|
|
325
|
+
and without `ambient` the unlit faces are pitch dark.
|
|
326
|
+
|
|
327
|
+
```json
|
|
328
|
+
{
|
|
329
|
+
"format": 1,
|
|
330
|
+
"type": "scene",
|
|
331
|
+
"dimension": "3d",
|
|
332
|
+
"name": "Hello",
|
|
333
|
+
"environment": {
|
|
334
|
+
"sky": { "type": "atmosphere", "elevationDeg": 35, "azimuthDeg": 140 },
|
|
335
|
+
"ambient": { "color": "#ffffff", "intensity": 0.2 },
|
|
336
|
+
"shadows": true
|
|
337
|
+
},
|
|
338
|
+
"root": {
|
|
339
|
+
"name": "World",
|
|
340
|
+
"type": "Node3D",
|
|
341
|
+
"children": [
|
|
342
|
+
{ "name": "Sun", "type": "DirectionalLight3D",
|
|
343
|
+
"props": { "position": [6, 10, 4], "intensity": 2, "castShadow": true } },
|
|
344
|
+
{ "name": "Camera", "type": "Camera3D",
|
|
345
|
+
"props": { "position": [7, 6, 9], "rotation": [-26, 36, 0], "current": true } },
|
|
346
|
+
{ "name": "Ground", "type": "MeshInstance3D",
|
|
347
|
+
"props": { "mesh": "plane", "size": [40, 1, 40], "receiveShadow": true,
|
|
348
|
+
"material": { "color": "#6b8f3a", "roughness": 1 } } },
|
|
349
|
+
{ "name": "Pivot", "type": "Node3D", "script": { "name": "Spin" },
|
|
350
|
+
"children": [
|
|
351
|
+
{ "name": "Cube", "type": "MeshInstance3D",
|
|
352
|
+
"props": { "mesh": "box", "position": [2, 0.5, 0], "castShadow": true,
|
|
353
|
+
"material": { "color": "#e0563f", "roughness": 0.4 } } },
|
|
354
|
+
{ "name": "Ball", "type": "MeshInstance3D",
|
|
355
|
+
"props": { "mesh": "sphere", "size": [0.6, 0.6, 0.6], "position": [-2, 0.6, 0],
|
|
356
|
+
"castShadow": true,
|
|
357
|
+
"material": { "color": "#4f8fe0", "metalness": 0.6, "roughness": 0.2 } } },
|
|
358
|
+
{ "name": "Gem", "type": "MeshInstance3D",
|
|
359
|
+
"props": { "mesh": "gem", "size": [0.5, 0.5, 0.5], "position": [0, 0.8, 2],
|
|
360
|
+
"castShadow": true,
|
|
361
|
+
"material": { "color": "#ffd166", "flatShading": true, "roughness": 0.1 } } }
|
|
362
|
+
] }
|
|
363
|
+
]
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Rotating the PIVOT orbits all three children — no maths in the behaviour:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { Behavior } from 'incanto';
|
|
372
|
+
|
|
373
|
+
export class Spin extends Behavior {
|
|
374
|
+
override update(dt: number): void {
|
|
375
|
+
const node = this.node as unknown as { rotation: number[] };
|
|
376
|
+
node.rotation = [0, (node.rotation[1] ?? 0) + 40 * dt, 0];
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Check it before you open a browser: `bunx incanto-check` says the scene is legal
|
|
382
|
+
and lit, and `bunx incanto-verify` walks the whole ladder.
|
|
325
383
|
|
|
326
384
|
## Atmosphere (sky, fog, shadows, exposure)
|
|
327
385
|
|
|
@@ -71,7 +71,14 @@ warn: World/Enemy: Chase.target — '%Playerr' matches no node in this scene.
|
|
|
71
71
|
|
|
72
72
|
— which is the one failure that used to be completely silent (a prop path
|
|
73
73
|
resolves with `getNodeOrNull`, so the scene opens and the enemy just never
|
|
74
|
-
chases).
|
|
74
|
+
chases). That is for a behaviour the ENGINE ships: `incanto-check` reads scene
|
|
75
|
+
files and cannot load your TypeScript, so a `nodePath: true` prop on a
|
|
76
|
+
behaviour of YOUR OWN is invisible to it. `auditScene` from `incanto/test`
|
|
77
|
+
gives the identical sentence once your behaviours are registered — which is why
|
|
78
|
+
every scaffolded `verify.ts` calls it before `runScript`, and why a
|
|
79
|
+
hand-written harness that skips it loses this check.
|
|
80
|
+
|
|
81
|
+
`Chase.target` and `FollowCamera.target` are `required`: an EMPTY one
|
|
75
82
|
is a load error naming the node, not a surprise in the browser.
|
|
76
83
|
|
|
77
84
|
**Never write `/root/<RootName>/...`.** If your root node is named `Game`, the
|
|
@@ -1189,6 +1196,16 @@ Multi-scene games: `flow.goToScene(nextSceneJson, { fadeSeconds: 0.4 })`
|
|
|
1189
1196
|
fades to black, swaps, fades back (headless = instant). Title → level →
|
|
1190
1197
|
next level is three JSON files and this one call.
|
|
1191
1198
|
|
|
1199
|
+
**A swap builds the physics world the incoming scene needs.** `physics: 'auto'`
|
|
1200
|
+
can only look at the scene the game BOOTS with, and a title screen has no
|
|
1201
|
+
bodies — so this used to leave the level with no physics at all: it rendered
|
|
1202
|
+
perfectly, nothing fell, nothing collided, nothing could be collected or hurt.
|
|
1203
|
+
`createGame2D`/`createGame3D` now hand the engine a way to build one, and the
|
|
1204
|
+
swap asks. It is asynchronous (Rapier is WASM), so the first frames after a
|
|
1205
|
+
swap into a physics scene run unsimulated — imperceptible, and the alternative
|
|
1206
|
+
was a game that never simulated. `physics: false` stays off across swaps, and a
|
|
1207
|
+
scene with no bodies still pays for nothing.
|
|
1208
|
+
|
|
1192
1209
|
## DayNight (live 24-hour cycle)
|
|
1193
1210
|
|
|
1194
1211
|
Drives the scene `environment` (needs an atmosphere sky): sun elevation/
|
|
@@ -253,6 +253,33 @@ that DOES serialize, on a node with no uid, is state that goes nowhere.
|
|
|
253
253
|
(`Health`, `ScoreKeeper`, `Collector`); a scene file cannot be asked whether
|
|
254
254
|
YOUR behavior serializes, so this walks the live tree and names those too.
|
|
255
255
|
|
|
256
|
+
## When the browser will not store anything
|
|
257
|
+
|
|
258
|
+
A private window, storage disabled, or an exhausted quota: `localStorage`
|
|
259
|
+
throws, and the store falls back to memory. **That fallback is right** —
|
|
260
|
+
refusing to save would be worse — but everything in the session still reads
|
|
261
|
+
healthy, so the player only finds out by reloading and losing the run. Measured
|
|
262
|
+
under Safari-private conditions: `set('highScore', 4200)` then `get(...)`
|
|
263
|
+
returned 4200, with zero warnings and no way to ask.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
if (!slots.persistent) {
|
|
267
|
+
banner.show('This browser will not keep your progress — private window?', { seconds: 6 });
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`SaveSlots.persistent` (and `SaveStore.persistent`) is `false` whenever writes
|
|
272
|
+
live only as long as the tab. The engine also says it once per namespace: on the
|
|
273
|
+
console, and — from `SavePoint` — through `engine.log`, so `incanto-logs` and
|
|
274
|
+
the `says` rung of `incanto-verify` see it too.
|
|
275
|
+
|
|
276
|
+
`SavePoint` still emits `saved(slot)` in that state, deliberately: it did save,
|
|
277
|
+
for as long as the page is open, and a Continue button that never lights up
|
|
278
|
+
would be a second bug. Ask `persistent` before promising the player anything.
|
|
279
|
+
|
|
280
|
+
Headless — tests, verify scripts, SSR — `persistent` is `false` and nothing is
|
|
281
|
+
logged: in-memory is the design there, not a failure.
|
|
282
|
+
|
|
256
283
|
## Saving from the scene — `SavePoint`
|
|
257
284
|
|
|
258
285
|
*When* to save is a design decision (checkpoint, level end, on quit) and only
|
|
@@ -116,6 +116,16 @@ The sections below are the same rungs one at a time, for when you need the detai
|
|
|
116
116
|
|
|
117
117
|
## 1. After EVERY scene edit: `incanto-check`
|
|
118
118
|
|
|
119
|
+
```bash
|
|
120
|
+
bunx incanto-check # structure, assets, semantics
|
|
121
|
+
bunx incanto-check --behaviors src/behaviors.ts --strict-behaviors
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`--strict-behaviors` makes a `script.name` that resolves nowhere a FAILURE
|
|
125
|
+
instead of a stub — and it needs `--behaviors` to know what you registered.
|
|
126
|
+
Without it, on any project with a behaviour of its own, it could only ever fail:
|
|
127
|
+
`Unknown behavior 'Hunter' … Did you forget registerBehavior?`
|
|
128
|
+
|
|
119
129
|
```bash
|
|
120
130
|
bunx incanto-check # validates every *.scene.json under cwd
|
|
121
131
|
bunx incanto-check --json # machine-readable (code + details.path/prop)
|
|
@@ -637,8 +647,9 @@ the path, then **▶ play ▸ ⤢ run as the game** and the page is the game aga
|
|
|
637
647
|
running your edit. `game.openEditor()` is the same switch from code.
|
|
638
648
|
|
|
639
649
|
It follows `debug` (so a build with the overlay has it) and loads lazily, so a
|
|
640
|
-
game that never opens it never downloads it. `editor: false` opts out
|
|
641
|
-
`editor: { save }` adds the editor's save
|
|
650
|
+
game that never opens it never downloads it. `editor: false` opts out at
|
|
651
|
+
RUNTIME — the button is absent — and `editor: { save }` adds the editor's save
|
|
652
|
+
button, wired to your own writer:
|
|
642
653
|
|
|
643
654
|
```ts
|
|
644
655
|
createGame3D({
|
|
@@ -660,6 +671,89 @@ overlay is a lazily-imported chunk — it stays out of the main bundle (no size
|
|
|
660
671
|
cost), but it still ships in the build, so that runtime `debug` flag is the only
|
|
661
672
|
thing gating it: keep it `false` (or dev-gated) in anything you publish.
|
|
662
673
|
|
|
674
|
+
## 9. Verify the thing you SHIP, not just the thing you run
|
|
675
|
+
|
|
676
|
+
Everything above measures a dev server. The artifact a player gets is
|
|
677
|
+
`bun run build` — a bundled, minified `dist/` with different asset paths and a
|
|
678
|
+
different module graph — and the boot path is the only code a BUILD adds over a
|
|
679
|
+
SCENE. Two of the nastiest defects this engine has had lived exactly there: a
|
|
680
|
+
`main.ts` whose scene swap left the game with no physics, and a boot that threw
|
|
681
|
+
behind a loading overlay that never came down.
|
|
682
|
+
|
|
683
|
+
`incanto-frame` and `incanto-logs` ask a dev server, and the dev server asks the
|
|
684
|
+
page over vite's HMR channel. A build has no HMR, so they used to answer:
|
|
685
|
+
|
|
686
|
+
```
|
|
687
|
+
? draws — no incanto dev server found.
|
|
688
|
+
? says — no incanto dev server found.
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
**`incanto-serve` closes that.** It serves your build as plain static files —
|
|
692
|
+
no bundler, no transform, byte-for-byte what you deploy — and adds only the
|
|
693
|
+
`/__incanto/*` endpoints the CLIs already speak, plus one inline script injected
|
|
694
|
+
into `index.html` at serve time. Nothing is written to disk, so what you measure
|
|
695
|
+
is what you ship.
|
|
696
|
+
|
|
697
|
+
```bash
|
|
698
|
+
bun run build # the artifact
|
|
699
|
+
bunx incanto-serve # ./dist on :4180 (or: incanto-serve build --port 5050)
|
|
700
|
+
# open http://127.0.0.1:4180/ , then:
|
|
701
|
+
bunx incanto-verify # draws and says now measure the BUILT game
|
|
702
|
+
bunx incanto-logs # what the built page is complaining about
|
|
703
|
+
bunx incanto-frame --out shipped.png
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
**Ship-check list**, in the order things actually break:
|
|
707
|
+
|
|
708
|
+
1. **`bun run build` at all.** Dev-only code (`import.meta.env.DEV`, a plugin
|
|
709
|
+
that is `apply: 'serve'`) can compile and then do nothing.
|
|
710
|
+
2. **Open the built page.** A blank screen with a console error is the common
|
|
711
|
+
failure; `incanto-logs` reads it without you opening devtools.
|
|
712
|
+
3. **`incanto-verify` against `incanto-serve`.** `draws` catches an asset base
|
|
713
|
+
path that survived dev and broke under a subdirectory deploy; `says` catches
|
|
714
|
+
the 404s.
|
|
715
|
+
4. **A phone-sized window.** See `incanto-web-integration.md` — a HUD that is
|
|
716
|
+
legible at 1280×800 can be 6 px tall at 390×844.
|
|
717
|
+
5. **A second load.** Saves, settings and unlocks come back; a private window
|
|
718
|
+
answers `slots.persistent === false` (see `incanto-save-slots.md`).
|
|
719
|
+
|
|
720
|
+
The one thing `incanto-serve` is not is a production server: it is a local tool
|
|
721
|
+
for looking at a build, on `127.0.0.1` by default.
|
|
722
|
+
|
|
723
|
+
### The editor is in your build, even in a 2D game
|
|
724
|
+
|
|
725
|
+
`editor: false` is a runtime option and **cannot remove bytes from a bundle**.
|
|
726
|
+
The editor chunk is lazy — a game that never opens it never downloads it — but
|
|
727
|
+
it is in `dist/`, and it imports `incanto/3d`, which imports rapier3d. Measured
|
|
728
|
+
on a pristine `platformer-2d` scaffold:
|
|
729
|
+
|
|
730
|
+
```
|
|
731
|
+
2236503 assets/rapier-*.js rapier3d ← a 2D game can never execute this
|
|
732
|
+
1692278 assets/rapier-*.js rapier2d ← the one it uses
|
|
733
|
+
471186 assets/3d-*.js the 3D adapter
|
|
734
|
+
─────────
|
|
735
|
+
5979588 total
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
A build-time decision needs a build-time lever:
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
import { incantoScenes, incantoTrim } from 'incanto/vite';
|
|
742
|
+
export default defineConfig({
|
|
743
|
+
plugins: [incantoScenes(), incantoTrim({ editor: false })],
|
|
744
|
+
});
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
```
|
|
748
|
+
5979588 → 2769972 bytes 53.7% smaller, same game
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Build only: the dev server keeps the editor, so `☰ debug ▸ ✎ edit this scene`
|
|
752
|
+
works the whole time you are making the game and is simply absent from what you
|
|
753
|
+
ship. Opening it in a trimmed build says so in one sentence rather than failing
|
|
754
|
+
obscurely. Reach for this when deploy size matters — a CDN, a jam upload, an
|
|
755
|
+
agent8 zip — and leave it off otherwise.
|
|
756
|
+
|
|
663
757
|
## 8. Debugging signals
|
|
664
758
|
|
|
665
759
|
- `this.log.info(...)` in behaviors — shows up in `runScript().logs` (and the
|
|
@@ -242,7 +242,8 @@ Things that cost real time, in the order you will meet them.
|
|
|
242
242
|
| a 3D character that feels right | `incanto-3d-character.md` |
|
|
243
243
|
| terrain, water, trees, sky | `incanto-environment.md` |
|
|
244
244
|
| sound | `incanto-audio.md` |
|
|
245
|
-
| shake, flash, hit-stop, particles | `incanto-
|
|
245
|
+
| shake, flash, hit-stop, particles | `incanto-gameplay-behaviors.md` (`CameraShake`, `screenFlash`, `hitStop`, `Particles2D/3D`) |
|
|
246
|
+
| is the feel RIGHT? measure it | `incanto-game-feel.md` |
|
|
246
247
|
| HUD, menus, inventory | `incanto-hud.md` |
|
|
247
248
|
| proving it works | `incanto-verifying-your-game.md` |
|
|
248
249
|
|
|
@@ -34,7 +34,17 @@ const game = await createGame3D({
|
|
|
34
34
|
throw e;
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
37
|
+
// The console handle FIRST, before any wiring of your own can throw.
|
|
38
|
+
// `game.stats()`, `game.assetErrors()`, `game.frame()` and `game.engine.log`
|
|
39
|
+
// are the entire in-page diagnostic surface, and a static build has no other.
|
|
40
|
+
// Assigned after the wiring, one mistake below took all of them with it.
|
|
40
41
|
(window as unknown as { game: typeof game }).game = game;
|
|
42
|
+
|
|
43
|
+
// The overlay comes down LAST, after any wiring of your own. There is none in
|
|
44
|
+
// this template — when you add some, put it above this line and wrap it:
|
|
45
|
+
//
|
|
46
|
+
// try { …your wiring… } catch (e) { showBootFailure(e); throw e; }
|
|
47
|
+
//
|
|
48
|
+
// A throw between the overlay coming down and the handle going up used to leave
|
|
49
|
+
// a level that renders perfectly with no player and no error of any kind.
|
|
50
|
+
document.querySelector('#loading')?.remove();
|
|
@@ -49,28 +49,6 @@
|
|
|
49
49
|
transform: rotate(360deg);
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
-
.round-btn {
|
|
53
|
-
position: fixed;
|
|
54
|
-
bottom: 2rem;
|
|
55
|
-
right: 2rem;
|
|
56
|
-
z-index: 1001;
|
|
57
|
-
width: 88px;
|
|
58
|
-
height: 88px;
|
|
59
|
-
border: none;
|
|
60
|
-
border-radius: 9999px;
|
|
61
|
-
background: rgba(255, 209, 102, 0.45);
|
|
62
|
-
color: #1d2a44;
|
|
63
|
-
font-weight: bold;
|
|
64
|
-
font-size: 0.9rem;
|
|
65
|
-
cursor: pointer;
|
|
66
|
-
user-select: none;
|
|
67
|
-
-webkit-user-select: none;
|
|
68
|
-
touch-action: none;
|
|
69
|
-
transition: transform 0.1s;
|
|
70
|
-
}
|
|
71
|
-
.round-btn:active {
|
|
72
|
-
transform: scale(0.9);
|
|
73
|
-
}
|
|
74
52
|
</style>
|
|
75
53
|
</head>
|
|
76
54
|
<body>
|
|
@@ -79,7 +57,6 @@
|
|
|
79
57
|
<div class="spinner"></div>
|
|
80
58
|
</div>
|
|
81
59
|
<canvas id="game"></canvas>
|
|
82
|
-
<button type="button" id="btn-jump" class="round-btn">JUMP</button>
|
|
83
60
|
<script>
|
|
84
61
|
// agent8 embed harness: report the game size to a hosting iframe parent.
|
|
85
62
|
function postGameSize() {
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
* HudUpdater — paint ScoreKeeper/hearts/lives into the HUD + win/lose banner.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { Node } from 'incanto';
|
|
22
|
+
import type { Node, UiBanner, UiText } from 'incanto';
|
|
23
23
|
import { Behavior } from 'incanto';
|
|
24
|
-
import type { AnimatedSprite2D, Camera2D, CharacterBody2D,
|
|
24
|
+
import type { AnimatedSprite2D, Camera2D, CharacterBody2D, Node2D } from 'incanto/2d';
|
|
25
25
|
import { Particles2D } from 'incanto/2d';
|
|
26
26
|
|
|
27
27
|
// ---- tuning (px, y-down; scene gravity is [0, 1800]) --------------------------
|
|
@@ -499,17 +499,27 @@ interface HeartState {
|
|
|
499
499
|
hearts: number;
|
|
500
500
|
}
|
|
501
501
|
export class HudUpdater extends Behavior {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
502
|
+
/*
|
|
503
|
+
* `UiText` under a `HudLayer`, not `Label` under a `UILayer`.
|
|
504
|
+
*
|
|
505
|
+
* A `Label` lives in the WORLD and scales with `viewport.design`. At
|
|
506
|
+
* 1280x800 this HUD painted at 20 device px; on a 390x844 phone the same
|
|
507
|
+
* nodes painted at 6.1 — an unreadable grey smear — because the viewport
|
|
508
|
+
* scale there is 0.406. The DOM widgets are declared in CSS pixels and are
|
|
509
|
+
* immune, which is why the engine's own volume sliders and touch controls
|
|
510
|
+
* stayed legible in the same capture while the flagship's HUD did not.
|
|
511
|
+
*/
|
|
512
|
+
private get coinLabel(): UiText {
|
|
513
|
+
return this.getNode('CoinLabel') as unknown as UiText;
|
|
514
|
+
}
|
|
515
|
+
private get heartLabel(): UiText {
|
|
516
|
+
return this.getNode('HeartLabel') as unknown as UiText;
|
|
517
|
+
}
|
|
518
|
+
private get livesLabel(): UiText {
|
|
519
|
+
return this.getNode('LivesLabel') as unknown as UiText;
|
|
520
|
+
}
|
|
521
|
+
private get banner(): UiBanner {
|
|
522
|
+
return this.getNode('/root/BannerLayer/Banner') as unknown as UiBanner;
|
|
513
523
|
}
|
|
514
524
|
private get score(): ScoreLike {
|
|
515
525
|
return this.getNode('/root').behavior as unknown as ScoreLike;
|
|
@@ -534,8 +544,8 @@ export class HudUpdater extends Behavior {
|
|
|
534
544
|
this.livesLabel.text = `x ${Math.max(0, this.score.lives)}`;
|
|
535
545
|
}
|
|
536
546
|
private show(text: string, color: string): void {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
547
|
+
// `UiBanner` owns its own fade and queue; a sticky one stays until the
|
|
548
|
+
// next `show`, which is what a win/lose screen wants.
|
|
549
|
+
this.banner.show(text, { color, seconds: 0 });
|
|
540
550
|
}
|
|
541
551
|
}
|
|
@@ -1848,52 +1848,49 @@
|
|
|
1848
1848
|
},
|
|
1849
1849
|
{
|
|
1850
1850
|
"name": "HUD",
|
|
1851
|
-
"type": "
|
|
1852
|
-
"props": {
|
|
1853
|
-
"anchor": "top-left"
|
|
1854
|
-
},
|
|
1851
|
+
"type": "HudLayer",
|
|
1855
1852
|
"script": {
|
|
1856
1853
|
"name": "HudUpdater"
|
|
1857
1854
|
},
|
|
1858
1855
|
"children": [
|
|
1859
1856
|
{
|
|
1860
1857
|
"name": "CoinLabel",
|
|
1861
|
-
"type": "
|
|
1858
|
+
"type": "UiText",
|
|
1862
1859
|
"props": {
|
|
1863
|
-
"
|
|
1860
|
+
"anchor": "topLeft",
|
|
1864
1861
|
"text": "◆ 0",
|
|
1865
|
-
"
|
|
1862
|
+
"size": 22,
|
|
1866
1863
|
"color": "#ffd166"
|
|
1867
1864
|
}
|
|
1868
1865
|
},
|
|
1869
1866
|
{
|
|
1870
1867
|
"name": "HeartLabel",
|
|
1871
|
-
"type": "
|
|
1868
|
+
"type": "UiText",
|
|
1872
1869
|
"props": {
|
|
1873
|
-
"
|
|
1870
|
+
"anchor": "topLeft",
|
|
1874
1871
|
"text": "♥♥♥",
|
|
1875
|
-
"
|
|
1872
|
+
"size": 22,
|
|
1876
1873
|
"color": "#ff6b81"
|
|
1877
1874
|
}
|
|
1878
1875
|
},
|
|
1879
1876
|
{
|
|
1880
1877
|
"name": "LivesLabel",
|
|
1881
|
-
"type": "
|
|
1878
|
+
"type": "UiText",
|
|
1882
1879
|
"props": {
|
|
1883
|
-
"
|
|
1880
|
+
"anchor": "topLeft",
|
|
1884
1881
|
"text": "x 3",
|
|
1885
|
-
"
|
|
1882
|
+
"size": 18,
|
|
1886
1883
|
"color": "#f4f4f4"
|
|
1887
1884
|
}
|
|
1888
1885
|
},
|
|
1889
1886
|
{
|
|
1890
1887
|
"name": "Hint",
|
|
1891
|
-
"type": "
|
|
1888
|
+
"type": "UiText",
|
|
1892
1889
|
"props": {
|
|
1893
|
-
"
|
|
1890
|
+
"anchor": "bottom",
|
|
1894
1891
|
"text": "A/D run · Space jump (double-jump!) · stomp goblins · reach the gold flag",
|
|
1895
|
-
"
|
|
1896
|
-
"color": "#
|
|
1892
|
+
"size": 14,
|
|
1893
|
+
"color": "#f4f4f4"
|
|
1897
1894
|
}
|
|
1898
1895
|
}
|
|
1899
1896
|
],
|
|
@@ -1901,20 +1898,13 @@
|
|
|
1901
1898
|
},
|
|
1902
1899
|
{
|
|
1903
1900
|
"name": "BannerLayer",
|
|
1904
|
-
"type": "
|
|
1905
|
-
"props": {
|
|
1906
|
-
"anchor": "center"
|
|
1907
|
-
},
|
|
1901
|
+
"type": "HudLayer",
|
|
1908
1902
|
"children": [
|
|
1909
1903
|
{
|
|
1910
1904
|
"name": "Banner",
|
|
1911
|
-
"type": "
|
|
1905
|
+
"type": "UiBanner",
|
|
1912
1906
|
"props": {
|
|
1913
|
-
"
|
|
1914
|
-
"text": "",
|
|
1915
|
-
"fontSize": 64,
|
|
1916
|
-
"color": "#ffffff",
|
|
1917
|
-
"align": "center"
|
|
1907
|
+
"size": 44
|
|
1918
1908
|
}
|
|
1919
1909
|
}
|
|
1920
1910
|
]
|