incanto 0.62.0 → 0.63.1
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-playtest.mjs +14 -5
- package/bin/incanto-verify.mjs +9 -4
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +22 -0
- package/dist/3d.js +5 -5
- package/dist/{create-game-BpunnGPX.js → create-game-BsqKOAFN.js} +6 -6
- package/dist/{create-game-Caut3bqN.js → create-game-CSnlQDkz.js} +6 -6
- package/dist/{duplicate-E4FUs5Bn.js → duplicate-BOOKmkQ7.js} +1 -1
- package/dist/{environment-presets-BAWeOeqf.js → environment-presets-BKRo-HXg.js} +3 -3
- package/dist/{gameplay-CaHqDiQD.js → gameplay-Dqm4LyKR.js} +30 -4
- package/dist/gameplay.js +1 -1
- package/dist/index.js +5 -5
- package/dist/{loader-DEe272nY.js → loader-zDynoew_.js} +57 -2
- package/dist/net.js +1 -1
- package/dist/{physics-2d-BXmu2i7W.js → physics-2d-BUD-MADM.js} +5 -5
- package/dist/{physics-3d-ClxP6Uv7.js → physics-3d-BsutZCtX.js} +5 -3
- package/dist/react.js +1 -1
- package/dist/{register-CNh4FlbD.js → register-BSA93acW.js} +3 -3
- package/dist/{register-D3yx8D4r.js → register-Btm7_Emq.js} +1 -1
- package/dist/{replay-DlgHItNv.js → replay-DKRmiVjk.js} +42 -2
- package/dist/{split-screen-CBM9wcMX.js → split-screen-Dx0LvzqS.js} +2 -2
- package/dist/{src-CH00_JsR.js → src-DQzZD_gx.js} +1 -1
- package/dist/{teardown-C7qP-dcC.js → teardown-BwhkcNt8.js} +1 -1
- package/dist/{test-BMkg8zMV.js → test-wPUuuJpP.js} +50 -18
- package/dist/test.d.ts +4 -0
- package/dist/test.js +2 -2
- package/dist/vite.js +2 -2
- package/editor/assets/{agent8-m5mtAO_A.js → agent8-CfacwdPM.js} +1 -1
- package/editor/assets/{debug-CPhzCT8f.js → debug-D0kX3cGf.js} +1 -1
- package/editor/assets/{index-D422P4kW.js → index-BrG3srCa.js} +92 -92
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +4 -0
- package/skills/README.md +4 -0
- package/skills/incanto-game-feel.md +3 -2
- package/skills/incanto-gameplay-behaviors.md +37 -6
- package/skills/incanto-node-reference.md +1 -0
- package/skills/incanto-physics-and-input.md +69 -7
- package/skills/incanto-scene-json-authoring.md +15 -1
- package/skills/incanto-your-first-game.md +242 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/star-survivor/src/game.scene.json +4 -2
- package/templates-app/tps-3d/PROJECT/Requirements.md +3 -1
- package/templates-app/tps-3d/index.html +3 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/behaviors.ts +4 -2
- package/templates-app/tps-3d/src/game.scene.json +3 -2
- package/templates-app/tps-3d/verify.ts +38 -0
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/grove.scene.json +6 -2
- package/templates-app/village-quest-3d/src/village.scene.json +8 -4
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-BrG3srCa.js"></script>
|
|
9
9
|
<link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
package/skills/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# incanto skills (shipped)
|
|
2
2
|
|
|
3
3
|
Full agent skills for building games with this **installed version** of Incanto.
|
|
4
|
+
**New here? `incanto-your-first-game.md` is the walkthrough** — scaffold to a game
|
|
5
|
+
with a real win and lose, the shape of the loop, and the traps that cost a first
|
|
6
|
+
author an hour. Then come back to this list.
|
|
7
|
+
|
|
4
8
|
An AI agent should start with `incanto-scene-json-authoring.md`, then the domain skill
|
|
5
9
|
for the task at hand, and **finish every task with `incanto-verifying-your-game.md`**
|
|
6
10
|
— you cannot see the game, and that skill is how you find out whether it works.
|
|
@@ -20,7 +20,8 @@ feel of /Game/Player
|
|
|
20
20
|
turnaround 367 ms
|
|
21
21
|
input → motion 1 frame(s)
|
|
22
22
|
|
|
23
|
-
jump apex 0.262 u
|
|
23
|
+
jump apex 0.262 u (tapped)
|
|
24
|
+
held apex 0.981 u ← size your level against THIS
|
|
24
25
|
time to apex 133 ms
|
|
25
26
|
airtime 267 ms
|
|
26
27
|
coyote window 100 ms
|
|
@@ -47,7 +48,7 @@ reports **0 ms**. That was a real bug in this engine's own 3D controller until
|
|
|
47
48
|
| `to a stop` | how much you slide. A platformer that overshoots ledges has a long one. |
|
|
48
49
|
| `turnaround` | the cost of changing your mind. The number players feel most and name least. |
|
|
49
50
|
| `input → motion` | 1 frame is immediate. More than 2 and something is buffering. |
|
|
50
|
-
| `jump apex` / `airtime` | compare against the gaps and platform heights in your level. |
|
|
51
|
+
| `jump apex` / `airtime` | compare against the gaps and platform heights in your level. **Use the HELD apex** — the plain one is a one-frame TAP, and with `jumpCutMultiplier` (the variable-height jump the 3D character skill recommends at `0.45`) that is a fraction of the real jump: measured 0.478 u tapped against 1.831 u held on the same scene. The held line only appears when the two differ. |
|
|
51
52
|
| `coyote window` | **0 reads to players as "the controls are broken"**, not as a missing feature. |
|
|
52
53
|
| `jump buffer` | 0 throws away a press made a few frames before landing. |
|
|
53
54
|
|
|
@@ -222,18 +222,47 @@ Signals: `totalChanged(total)` — Methods: `collect(value)` — state: `total`
|
|
|
222
222
|
Deals damage to whatever it touches — projectiles, spikes, lava, enemy hitboxes.
|
|
223
223
|
**Goes on an Area.** On `triggerEnter`, finds the contacted entity's `Health` and
|
|
224
224
|
calls `damage(amount)`, then emits `dealtDamage(amount, healthOwnerNode)`.
|
|
225
|
-
`oncePerTarget` (default) prevents re-hitting a resting body every frame;
|
|
226
|
-
`destroySelf` frees the hazard after a hit (single-use projectiles).
|
|
227
225
|
|
|
228
226
|
| Prop | Default | Meaning |
|
|
229
227
|
|---|---|---|
|
|
230
228
|
| `amount` | `10` | HP removed per contact |
|
|
231
|
-
| `targetGroup` | `""` | only damage targets
|
|
232
|
-
| `oncePerTarget` | `true` | damage each target at most once |
|
|
229
|
+
| `targetGroup` | `""` | only damage targets in this group — the tag may sit on the BODY or on the node carrying its `Health` (`""` = any) |
|
|
230
|
+
| `oncePerTarget` | `true` | damage each target at most **once, ever** |
|
|
231
|
+
| `repeatEvery` | `0` | seconds between re-hits while still overlapping (`0` = entry only) |
|
|
233
232
|
| `destroySelf` | `false` | `queueFree()` after the first hit |
|
|
234
233
|
|
|
235
234
|
Signals: `dealtDamage(amount, target)`
|
|
236
235
|
|
|
236
|
+
### `repeatEvery` is the "and it keeps hurting" knob
|
|
237
|
+
|
|
238
|
+
**Contact events fire on ENTRY and EXIT, not per frame.** So `oncePerTarget:
|
|
239
|
+
false` does not mean "hurt every frame" — it means "hurt again on RE-entry". An
|
|
240
|
+
enemy that closes on you and stops moving has entered once, and deals one hit
|
|
241
|
+
and then nothing. Measured, 6 seconds of unbroken overlap at `amount: 10`:
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
oncePerTarget=false repeatEvery=0 → hp 90 ← one hit, then nothing
|
|
245
|
+
oncePerTarget=false repeatEvery=0.5 → hp 0
|
|
246
|
+
oncePerTarget=true repeatEvery=0.5 → hp 0
|
|
247
|
+
oncePerTarget=true repeatEvery=0 → hp 90
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`repeatEvery` is the only prop that makes a resting overlap keep hurting, and it
|
|
251
|
+
overrides `oncePerTarget` — lava pools, poison clouds, an enemy standing on you.
|
|
252
|
+
Set it on **every enemy contact hitbox**; without it a game plays as though the
|
|
253
|
+
enemies are harmless the moment one of them corners you, and every check comes
|
|
254
|
+
back green.
|
|
255
|
+
|
|
256
|
+
Pair it with `Health.invulnerableFor`, which is the real "don't drain me every
|
|
257
|
+
frame" guard: with `invulnerableFor: 0.6`, a `repeatEvery` under 0.6 s just
|
|
258
|
+
lands on i-frames, so 0.5–1 s is the useful range.
|
|
259
|
+
|
|
260
|
+
The tag may sit on either end of the entity, because they are commonly
|
|
261
|
+
different nodes: a body holds one behavior, so a player with its own script
|
|
262
|
+
keeps its `Health` on a child, and the group stays on the body. `incanto check`
|
|
263
|
+
reports a `targetGroup` that nothing in the scene can satisfy — a hazard that is
|
|
264
|
+
wired, validated and disarmed is otherwise invisible.
|
|
265
|
+
|
|
237
266
|
**`targetGroup` — stop enemies killing each other.** With no collision layers in
|
|
238
267
|
v0 (everything overlaps everything), an enemy's contact hitbox would damage any
|
|
239
268
|
`Health` it touches, including other enemies clustered at the spawn point. Gate
|
|
@@ -693,7 +722,8 @@ interaction is a `connection`.
|
|
|
693
722
|
"script": { "name": "Pickup", "props": { "value": 1 } } },
|
|
694
723
|
{ "name": "Spike", "type": "Area2D", "props": { "position": [120, 80],
|
|
695
724
|
"collider": { "shape": "rect", "size": [24, 24] } },
|
|
696
|
-
"script": { "name": "DamageOnContact",
|
|
725
|
+
"script": { "name": "DamageOnContact",
|
|
726
|
+
"props": { "amount": 40, "oncePerTarget": false, "repeatEvery": 0.5 } } }
|
|
697
727
|
]
|
|
698
728
|
},
|
|
699
729
|
"connections": [
|
|
@@ -778,7 +808,8 @@ glue an early sim had to hand-write):
|
|
|
778
808
|
{ "name": "Hit", "type": "Area2D",
|
|
779
809
|
"props": { "collider": { "shape": "circle", "radius": 12 } },
|
|
780
810
|
"script": { "name": "DamageOnContact",
|
|
781
|
-
"props": { "amount": 10, "targetGroup": "player",
|
|
811
|
+
"props": { "amount": 10, "targetGroup": "player",
|
|
812
|
+
"oncePerTarget": false, "repeatEvery": 0.5 } } }
|
|
782
813
|
] }
|
|
783
814
|
] }
|
|
784
815
|
]
|
|
@@ -239,6 +239,7 @@ Signals: `triggerEnter(other)` · `triggerExit(other)`
|
|
|
239
239
|
| `velocity` | `[0,0,0]` | array |
|
|
240
240
|
| `stickToGround` | `true` | boolean |
|
|
241
241
|
| `slopeLimitDeg` | `45` | number |
|
|
242
|
+
| `stepHeight` | `0.35` | number |
|
|
242
243
|
|
|
243
244
|
Signals: `triggerEnter(other)` · `triggerExit(other)`
|
|
244
245
|
|
|
@@ -357,6 +357,36 @@ the tree says, without your scene falling over while you look at it. Use it for
|
|
|
357
357
|
any "show me the collision geometry" tool. Do NOT use it to freeze a running
|
|
358
358
|
game — that is `engine.timeScale = 0`, which keeps the world coherent.
|
|
359
359
|
|
|
360
|
+
## A walking body climbs a step (`stepHeight`, 3D)
|
|
361
|
+
|
|
362
|
+
`CharacterBody3D.stepHeight` (default `0.35` m, `0` = off) is how high a ledge
|
|
363
|
+
the body walks UP without jumping. It is Rapier's autostep, and **Rapier does not
|
|
364
|
+
autostep unless it is asked** — before 0.63 nothing asked, so any walking body
|
|
365
|
+
was stopped dead by a curb.
|
|
366
|
+
|
|
367
|
+
You will not see this on the player: `CharacterController3D` rides a hover spring
|
|
368
|
+
rather than the character controller, so it floats over small ledges already.
|
|
369
|
+
It is the ENEMIES that walk, and a chaser walks a straight line — it cannot go
|
|
370
|
+
around. Measured on the shipped `tps-3d` template, whose arena has a 0.6 m ramp
|
|
371
|
+
between the spawn and the player, standing still for 45 seconds:
|
|
372
|
+
|
|
373
|
+
```
|
|
374
|
+
before hits=0 hp=100 closest an enemy ever got = 33.91m
|
|
375
|
+
after hits=9 hp=.. closest = 1.80m (exactly Chase.stopRange)
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
They chased at full speed the whole time and piled up against the near face of a
|
|
379
|
+
knee-high box. Raise `stepHeight` for a world with real stairs; set it to `0` for
|
|
380
|
+
something that genuinely should be blocked by a curb.
|
|
381
|
+
|
|
382
|
+
**It loses to a large downward velocity.** Autostep happens inside the movement
|
|
383
|
+
solve, so a body driven with a big constant gravity term every frame never
|
|
384
|
+
climbs. Same 0.3 m ledge, same `stepHeight: 0.35`, only the `velocity[1]`
|
|
385
|
+
differs: `0` → cleared, `−0.1` → cleared, `−2` → stopped at the face. If you are
|
|
386
|
+
integrating your own gravity, apply it as a falling SPEED that resets on the
|
|
387
|
+
ground, not as a constant push. (`moveBody`, which `Chase` and `Patrol` use,
|
|
388
|
+
derives velocity from a position delta and carries no such term.)
|
|
389
|
+
|
|
360
390
|
## Moving platforms carry their riders (2D **and** 3D)
|
|
361
391
|
|
|
362
392
|
A `CharacterBody2D`/`CharacterBody3D` standing on a body that moves is dragged
|
|
@@ -398,14 +428,46 @@ From scene JSON, the **`Clickable`** behavior needs no code at all:
|
|
|
398
428
|
| `maxDistance` | `0` | ignore clicks further than this (0 = any) |
|
|
399
429
|
| `enabled` | `true` | stop responding without detaching |
|
|
400
430
|
|
|
401
|
-
Signals: **`clicked`**, **`hovered`**, **`unhovered`** — wire
|
|
402
|
-
`connections` like any other.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
431
|
+
Signals: **`clicked(node)`**, **`hovered(node)`**, **`unhovered(node)`** — wire
|
|
432
|
+
them in `connections` like any other. **Each one carries the node it happened
|
|
433
|
+
on**, so one handler can serve a whole board of tiles:
|
|
434
|
+
|
|
435
|
+
```jsonc
|
|
436
|
+
{ "signal": "clicked", "from": "/Board/Tile3", "to": "/Board", "handler": "onTileClicked" }
|
|
437
|
+
```
|
|
438
|
+
```ts
|
|
439
|
+
onTileClicked(tile: Node) { this.flip(tile); } // which tile, without a wire each
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
`clicked` fires on RELEASE over the same node the press started on (a drag that
|
|
443
|
+
ends elsewhere is not a click, the way every button on every platform behaves),
|
|
444
|
+
and a hit on a CHILD counts as a hit on the node — the raycast lands on the
|
|
445
|
+
visual mesh, which is usually a child.
|
|
446
|
+
|
|
447
|
+
### Testing a mouse-driven game headlessly
|
|
448
|
+
|
|
449
|
+
There is no renderer and therefore no raycast, so `engine.picker` is null and
|
|
450
|
+
`Clickable` is inert. That is not a dead end: give the engine a picker of your
|
|
451
|
+
own and drive the cursor.
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
// A picker that answers from the tree instead of a GPU raycast.
|
|
455
|
+
engine.picker = (x, y) => hitTestYourBoard(x, y); // return a Node or null
|
|
456
|
+
|
|
457
|
+
engine.input.setPointerPosition(150, 0); // where the cursor IS
|
|
458
|
+
engine.input.handleMouseButton(0, true); // press …
|
|
459
|
+
engine.step();
|
|
460
|
+
engine.input.handleMouseButton(0, false); // … and release
|
|
461
|
+
engine.step(); // `clicked` fires here
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Note the BUTTON, not an action: `Clickable` reads `mouseJustPressed` directly,
|
|
465
|
+
so `pressAction('click')` drives nothing. Moving the cursor between the press
|
|
466
|
+
and the release correctly produces no click.
|
|
406
467
|
|
|
407
|
-
|
|
408
|
-
|
|
468
|
+
Without this a mouse game's entire input surface is load-validated and never
|
|
469
|
+
once executed — the scene is legal, every wire resolves, and nothing has ever
|
|
470
|
+
been clicked.
|
|
409
471
|
|
|
410
472
|
## Placement rules
|
|
411
473
|
|
|
@@ -299,7 +299,21 @@ that do exist at the failing spot).
|
|
|
299
299
|
- `signal` must be DECLARED on the from node (the class's or its behavior's
|
|
300
300
|
`static signals`) — validated at load → `UNKNOWN_SIGNAL` otherwise.
|
|
301
301
|
- `handler` must be a method on the target node OR its behavior (script) — both are
|
|
302
|
-
validated hard at load. Otherwise → `UNKNOWN_HANDLER`.
|
|
302
|
+
validated hard at load. Otherwise → `UNKNOWN_HANDLER`.
|
|
303
|
+
- **The ARGUMENTS are checked when the wire fires**, not at load — nothing declares
|
|
304
|
+
how many a signal carries, so the pair can only be compared at the moment both
|
|
305
|
+
are known. A handler given fewer than it needs is reported once, by name:
|
|
306
|
+
|
|
307
|
+
```
|
|
308
|
+
[incanto] 'died' from 'Enemy' carries 0 argument(s) and 'Keeper.addScore' needs 1.
|
|
309
|
+
The missing one(s) arrive as undefined — a number handler gets NaN and
|
|
310
|
+
never recovers.
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`died → ScoreKeeper.addScore` is the natural way to score a kill and it sets the
|
|
314
|
+
score to `NaN` on the first one, with the win condition then permanently out of
|
|
315
|
+
reach. A handler that DEFAULTS what it is not given is correct and stays silent:
|
|
316
|
+
`won → GameFlow.win` works, because `win(text = 'YOU WIN')` needs nothing. On **both**, it is
|
|
303
317
|
`AMBIGUOUS_HANDLER`: the node's method wins, so the script's would never run
|
|
304
318
|
and nothing would say so. A core node answers to 62 public methods before any
|
|
305
319
|
adapter adds more (`stop` `play` `show` `clear` `say` `start` `free` …), which
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: incanto-your-first-game
|
|
3
|
+
description: The walkthrough — scaffold, author, verify, hand back. A complete game with a real win and lose in one sitting, the shape of the loop, and the handful of traps that cost every first-time author an hour. Read this FIRST if you have not shipped an Incanto game before.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Your first Incanto game
|
|
7
|
+
|
|
8
|
+
> Shipped inside the `incanto` npm package — this document always matches the
|
|
9
|
+
> installed engine version. Sibling skills live in `node_modules/incanto/skills/`.
|
|
10
|
+
|
|
11
|
+
This is the shortest honest path from nothing to a game with a win and a lose in
|
|
12
|
+
it. Everything below has been built and measured; the timings are real, and so
|
|
13
|
+
are the traps.
|
|
14
|
+
|
|
15
|
+
**You cannot see this game.** No browser, no GPU, no screenshot. That sounds like
|
|
16
|
+
the hard part and it is not — the engine ships the instruments, and §5 is how you
|
|
17
|
+
use them. What actually costs first-time authors an hour is §4, so read it even
|
|
18
|
+
if you skim.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. Three minutes to a running game
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bunx incanto@latest new --list # what the starters are
|
|
26
|
+
bunx incanto@latest new my-game --template tps-3d # or platformer-2d, star-survivor, …
|
|
27
|
+
cd my-game && bun install
|
|
28
|
+
bun run check && bun run typecheck && bun run verify
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`@latest` matters: `bunx` caches the CLI, and a cached one scaffolds a project
|
|
32
|
+
pinned to ITS version, so you can quietly get an engine a release behind the
|
|
33
|
+
docs you are reading. Check with `grep incanto package.json` if anything below
|
|
34
|
+
does not match what you see.
|
|
35
|
+
|
|
36
|
+
The starter is a **complete game**, green on arrival, and it is meant to be
|
|
37
|
+
reshaped rather than read. Four agents building four different games all started
|
|
38
|
+
here; the two who hand-authored a scene from scratch still scaffolded first, for
|
|
39
|
+
the vite config and the verify harness.
|
|
40
|
+
|
|
41
|
+
Pick by shape, not by subject: `tps-3d` (third-person combat), `platformer-2d`,
|
|
42
|
+
`star-survivor` (top-down survivor), `village-quest-3d` (quest/NPC),
|
|
43
|
+
`beacon-isle-3d` (open world).
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 2. What you edit
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
src/game.scene.json ← the game. Nodes, props, connections. This is most of it.
|
|
51
|
+
src/behaviors.ts ← the parts JSON cannot express. Usually very little.
|
|
52
|
+
src/main.ts ← boot. You rarely touch it.
|
|
53
|
+
verify.ts ← the harness that proves it works. You WILL touch it.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The claim that "all structure is JSON" is not marketing: one of the four builds
|
|
57
|
+
shipped a whole 3D world with **zero** gameplay TypeScript, and another needed
|
|
58
|
+
90 lines for one melee swing. Reach for a built-in behavior before you write a
|
|
59
|
+
class — `incanto-gameplay-behaviors.md` is the list, and it is long.
|
|
60
|
+
|
|
61
|
+
Read `incanto-scene-json-authoring.md` before you write JSON. It is the format,
|
|
62
|
+
the node paths, and the connection grammar, and everything else assumes it.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 3. The spine: a game that can be WON and LOST
|
|
67
|
+
|
|
68
|
+
This is the part worth copying verbatim. It is four nodes and four connections,
|
|
69
|
+
and it is entirely JSON.
|
|
70
|
+
|
|
71
|
+
```jsonc
|
|
72
|
+
{ "name": "Score", "type": "Node3D",
|
|
73
|
+
"script": { "name": "ScoreKeeper", "props": { "scoreToWin": 10, "lives": 3 } } },
|
|
74
|
+
|
|
75
|
+
{ "name": "Flow", "type": "Node3D", "script": { "name": "GameFlow" } },
|
|
76
|
+
|
|
77
|
+
{ "name": "HUD", "type": "HudLayer", "children": [
|
|
78
|
+
{ "name": "Banner", "type": "UiBanner" },
|
|
79
|
+
{ "name": "Hp", "type": "UiBar", "props": { "anchor": "topLeft", "label": "HP" } }
|
|
80
|
+
] }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```jsonc
|
|
84
|
+
"connections": [
|
|
85
|
+
{ "signal": "died", "from": "Player", "to": "Score", "handler": "loseLife" },
|
|
86
|
+
{ "signal": "won", "from": "Score", "to": "Flow", "handler": "win" },
|
|
87
|
+
{ "signal": "lost", "from": "Score", "to": "Flow", "handler": "gameOver" }
|
|
88
|
+
]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`GameFlow` freezes `engine.timeScale`, shows a sticky banner, and waits for the
|
|
92
|
+
`restart` action. **Give the Flow its own node** — a node holds one behavior and
|
|
93
|
+
your root probably already has the game's director script.
|
|
94
|
+
|
|
95
|
+
A pause menu is the same trick and also zero TypeScript: declare a `pause`
|
|
96
|
+
action, add a `UiPanel` named `PauseMenu` under the HUD, and Escape opens it.
|
|
97
|
+
|
|
98
|
+
### The trap that eats an hour here
|
|
99
|
+
|
|
100
|
+
`died` carries **no arguments**. `ScoreKeeper.addScore(n)` needs one. So the
|
|
101
|
+
obvious way to score a kill —
|
|
102
|
+
|
|
103
|
+
```jsonc
|
|
104
|
+
// WRONG — this is the mistake, not the fix
|
|
105
|
+
{ "signal": "died", "from": "Enemy", "to": "Score", "handler": "addScore" }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
— sets the score to `NaN` on the first kill, and the win condition is
|
|
109
|
+
unreachable forever. The engine reports this the moment the wire fires; do not
|
|
110
|
+
ignore that line. Wire `dealtDamage` from the KILLER instead (it carries the
|
|
111
|
+
amount and the target), which is also clone-safe — a connection on a spawned
|
|
112
|
+
enemy never clones.
|
|
113
|
+
|
|
114
|
+
`won → GameFlow.win` is fine, by contrast, because `win(text = 'YOU WIN')`
|
|
115
|
+
defaults what it is not given. That is the difference, and it is the only one.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 4. Making things hurt (read this one)
|
|
120
|
+
|
|
121
|
+
An enemy that touches you should drain you. Two props decide whether it does,
|
|
122
|
+
and both defaults are the harmless answer.
|
|
123
|
+
|
|
124
|
+
**Contact fires on ENTRY and EXIT, never per frame.** So `oncePerTarget: false`
|
|
125
|
+
means "hurt again on RE-entry" — an enemy that closes and stops deals one hit and
|
|
126
|
+
then nothing at all. `repeatEvery` is the knob that makes a resting overlap keep
|
|
127
|
+
hurting. Six seconds of unbroken contact at `amount: 10`:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
oncePerTarget=false repeatEvery=0 → hp 90 ← one hit, then nothing
|
|
131
|
+
oncePerTarget=false repeatEvery=0.5 → hp 0
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```jsonc
|
|
135
|
+
{ "name": "Hit", "type": "Area3D",
|
|
136
|
+
"props": { "collider": { "shape": "sphere", "radius": 1.5 } },
|
|
137
|
+
"script": { "name": "DamageOnContact",
|
|
138
|
+
"props": { "amount": 12, "targetGroup": "player",
|
|
139
|
+
"oncePerTarget": false, "repeatEvery": 0.5 } } }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Pair it with `Health.invulnerableFor` (the real per-frame guard) and keep
|
|
143
|
+
`repeatEvery` at or above it — under 0.6 s it just lands on i-frames.
|
|
144
|
+
|
|
145
|
+
**And they have to be able to REACH you.** A `CharacterBody3D` walks; a chaser
|
|
146
|
+
walks a straight line and cannot go around. `stepHeight` (default 0.35 m) is how
|
|
147
|
+
high a ledge it climbs — raise it for a world with stairs. You will not notice
|
|
148
|
+
this on the player, because `CharacterController3D` rides a hover spring and
|
|
149
|
+
floats over small ledges already.
|
|
150
|
+
|
|
151
|
+
The tell for both is one line of `incanto-playtest` output:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
danger: the player took damage 0 times in 4 runs — nothing here can hurt you
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Read it. It is the truth.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 5. Handing it back
|
|
162
|
+
|
|
163
|
+
Never hand a game back on reasoning. Run the ladder:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
bunx incanto verify # loads · plays · feels · agrees · draws · says
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- **`loads`** — the scene is legal and its assets resolve. Warnings print under
|
|
170
|
+
it with `!`; a scene that "renders black" says so here.
|
|
171
|
+
- **`plays`** — 8 seeded runs. `error`, `fell` and `stuck` are DEFECTS and fail
|
|
172
|
+
the rung; `won`, `lost` and `unfinished` are gameplay. A random bot cannot
|
|
173
|
+
finish a quest, and that is reported as unmeasured, not failed.
|
|
174
|
+
- **`feels`** — the sounds and effects the scene declares, against what actually
|
|
175
|
+
fired. A game whose feedback is wired and never triggered plays perfectly and
|
|
176
|
+
feels dead.
|
|
177
|
+
- **`draws` / `says`** — need a dev server with the page open. Unmeasured is not
|
|
178
|
+
failed.
|
|
179
|
+
|
|
180
|
+
Then the two that answer questions the ladder cannot:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts
|
|
184
|
+
bunx incanto-feel src/game.scene.json --behaviors src/behaviors.ts
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`playtest` gives you a difficulty read (`won 4/20, lost 9/20, 9.7 hits per run`)
|
|
188
|
+
that no amount of staring at JSON will. `feel` measures your controls by probing
|
|
189
|
+
them — and prints the **held** jump apex next to the tapped one, because with
|
|
190
|
+
`jumpCutMultiplier` those differ by 4× and the held number is the one your level
|
|
191
|
+
geometry has to match.
|
|
192
|
+
|
|
193
|
+
Full detail: `incanto-verifying-your-game.md`, `incanto-playtesting.md`,
|
|
194
|
+
`incanto-game-feel.md`.
|
|
195
|
+
|
|
196
|
+
### Write the harness, not just the checks
|
|
197
|
+
|
|
198
|
+
`verify.ts` in the starter drives the game with `runScript` and asserts what
|
|
199
|
+
happened. Extend it as you build; it is the only thing that will catch a
|
|
200
|
+
regression you cannot see.
|
|
201
|
+
|
|
202
|
+
**Assert the game's own physics, not your test's.** A harness that emits
|
|
203
|
+
`triggerEnter` by hand proves the handler answers an event and nothing about
|
|
204
|
+
whether the game produces one — that exact mistake hid a starter whose enemies
|
|
205
|
+
could not reach the player. Let the AI chase, and read the health.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 6. The sticky note
|
|
210
|
+
|
|
211
|
+
Things that cost real time, in the order you will meet them.
|
|
212
|
+
|
|
213
|
+
| when | the trap |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| wiring a score | `died` carries nothing; `addScore(n)` wants one → `NaN`. Wire `dealtDamage` from the killer. |
|
|
216
|
+
| enemies feel harmless | `repeatEvery` on the contact hitbox, or one hit is all you get. |
|
|
217
|
+
| enemies never arrive | a chaser cannot climb — `stepHeight`, and it loses to a large downward velocity you apply yourself. |
|
|
218
|
+
| sizing a level | use the **held** jump apex, not the tapped one. |
|
|
219
|
+
| a melee weapon | a body with `Health` on the root and a `Hit` child presents TWO colliders; one swing can deal damage twice. Give the weapon a `targetGroup`. |
|
|
220
|
+
| a HUD you cannot see | `UiText.setText()` fills a slot; the `text` prop keeps the authored line. Use `describeCapture` to read what a widget PAINTS. |
|
|
221
|
+
| framing a scene | `describeFraming(scene)` returns the report object; `framingText(report)` renders it. |
|
|
222
|
+
| spawned enemies | connections on a template do NOT clone. Put the wire on something that is not cloned, or emit from a behavior. |
|
|
223
|
+
| a scene that swaps scenes | `incanto-playtest` drives one scene; a mid-run swap is out of its reach. Script it in `verify.ts` instead. |
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 7. Where to go next
|
|
228
|
+
|
|
229
|
+
| you want | read |
|
|
230
|
+
| --- | --- |
|
|
231
|
+
| the JSON format itself | `incanto-scene-json-authoring.md` |
|
|
232
|
+
| nodes, props, defaults | `incanto-node-reference.md` (generated — always current) |
|
|
233
|
+
| ready-made game logic | `incanto-gameplay-behaviors.md` |
|
|
234
|
+
| a 3D character that feels right | `incanto-3d-character.md` |
|
|
235
|
+
| terrain, water, trees, sky | `incanto-environment.md` |
|
|
236
|
+
| sound | `incanto-audio.md` |
|
|
237
|
+
| shake, flash, hit-stop, particles | `incanto-game-feel.md` |
|
|
238
|
+
| HUD, menus, inventory | `incanto-hud.md` |
|
|
239
|
+
| proving it works | `incanto-verifying-your-game.md` |
|
|
240
|
+
|
|
241
|
+
And when a game "works" but feels wrong, the answer is almost always in
|
|
242
|
+
`incanto-feel` output you have not run yet.
|
|
@@ -220,7 +220,8 @@
|
|
|
220
220
|
"props": {
|
|
221
221
|
"amount": 8,
|
|
222
222
|
"targetGroup": "player",
|
|
223
|
-
"oncePerTarget": false
|
|
223
|
+
"oncePerTarget": false,
|
|
224
|
+
"repeatEvery": 0.5
|
|
224
225
|
}
|
|
225
226
|
}
|
|
226
227
|
},
|
|
@@ -332,7 +333,8 @@
|
|
|
332
333
|
"props": {
|
|
333
334
|
"amount": 12,
|
|
334
335
|
"targetGroup": "player",
|
|
335
|
-
"oncePerTarget": false
|
|
336
|
+
"oncePerTarget": false,
|
|
337
|
+
"repeatEvery": 0.5
|
|
336
338
|
}
|
|
337
339
|
}
|
|
338
340
|
},
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
strafe-aim rig (always face the camera) would be a custom controller tweak.
|
|
32
32
|
- Mouse look needs POINTER LOCK (click the canvas). A free cursor never spins the
|
|
33
33
|
camera (deltas only accumulate while locked or a button is held).
|
|
34
|
-
- HUD is DOM, not
|
|
34
|
+
- HUD is DOM here by CHOICE, not by necessity: `HudLayer` + `UiText`/`UiBar`
|
|
35
|
+
work in 3D too and are the ones a headless check can read (incanto-hud.md),
|
|
36
|
+
and this scene keeps a `HudLayer` for its banner. The crosshair,
|
|
35
37
|
HUD and banner live in `index.html`; `HudUpdater` fills them (guarded for
|
|
36
38
|
headless). The crosshair marks the camera aim point, not the gun barrel.
|
|
37
39
|
- Zero-asset: arena, soldier and enemies are primitives; SFX are procedural
|
|
@@ -110,7 +110,9 @@
|
|
|
110
110
|
width: 56px;
|
|
111
111
|
height: 56px;
|
|
112
112
|
}
|
|
113
|
-
/*
|
|
113
|
+
/* This game's HUD is plain DOM. `HudLayer` + `UiText`/`UiBar` work in 3D
|
|
114
|
+
too (see incanto-hud.md) and are readable headlessly — the scene has one
|
|
115
|
+
for the banner. Either is fine; DOM here is a choice, not a limit. */
|
|
114
116
|
#hud {
|
|
115
117
|
position: fixed;
|
|
116
118
|
top: 14px;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* snaps the visible character to FACE the aim on each shot so the soldier turns
|
|
12
12
|
* to shoot where you're looking.
|
|
13
13
|
* - `HudUpdater` — pure presentation glue. The gameplay library owns the numbers,
|
|
14
|
-
* not how they're drawn, and
|
|
14
|
+
* not how they're drawn, and this game draws its HUD in plain DOM, so it reads
|
|
15
15
|
* ScoreKeeper.score / Health.current / the wave index / the ammo count each
|
|
16
16
|
* frame into the HTML HUD overlay (index.html) and flips the win/lose banner.
|
|
17
17
|
*/
|
|
@@ -315,7 +315,9 @@ export class Shoot extends Behavior {
|
|
|
315
315
|
|
|
316
316
|
// ---------------------------------------------------------------------------
|
|
317
317
|
// HudUpdater — writes the live numbers into the HTML HUD overlay (index.html).
|
|
318
|
-
//
|
|
318
|
+
// This HUD is plain DOM by choice. `HudLayer` + `UiText`/`UiBar` also work in
|
|
319
|
+
// 3D and are the readable-headlessly option (incanto-hud.md); the scene keeps a
|
|
320
|
+
// HudLayer for its banner. All DOM
|
|
319
321
|
// access is guarded: headless runs (verify, runScript) have no `document`, so
|
|
320
322
|
// every write is a no-op and the gameplay logic is unaffected.
|
|
321
323
|
// ---------------------------------------------------------------------------
|
|
@@ -629,7 +629,8 @@
|
|
|
629
629
|
"props": {
|
|
630
630
|
"amount": 12,
|
|
631
631
|
"targetGroup": "player",
|
|
632
|
-
"oncePerTarget": false
|
|
632
|
+
"oncePerTarget": false,
|
|
633
|
+
"repeatEvery": 0.5
|
|
633
634
|
}
|
|
634
635
|
}
|
|
635
636
|
},
|
|
@@ -698,7 +699,7 @@
|
|
|
698
699
|
"name": "ResumeHint",
|
|
699
700
|
"type": "UiText",
|
|
700
701
|
"props": {
|
|
701
|
-
"text": "Esc to resume
|
|
702
|
+
"text": "Esc to resume \u00b7 R to restart",
|
|
702
703
|
"size": 13,
|
|
703
704
|
"color": "#c9d2dd"
|
|
704
705
|
}
|