incanto 0.36.2 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/incanto-feel.mjs +93 -0
- package/bin/incanto-playtest.mjs +136 -0
- package/dist/2d.d.ts +2 -2
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +14 -3
- package/dist/3d.js +4 -4
- package/dist/{behavior-DibCwrW7.d.ts → behavior-5o1EkbLD.d.ts} +11 -0
- package/dist/{create-game-D-StzrTj.js → create-game-DmGc-Mrn.js} +5 -5
- package/dist/{create-game-DnOu2aAa.js → create-game-QqrrK3yr.js} +5 -5
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-Cvb1BSca.js → duplicate-CRtihGmC.js} +1 -1
- package/dist/{environment-presets-fK9oyrrR.js → environment-presets-DWHxLHM5.js} +75 -9
- package/dist/{gameplay-C0MUMSak.js → gameplay-CsJDUQh_.js} +2 -2
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +4 -50
- package/dist/index.js +5 -75
- package/dist/{loader-BqigZsfF.d.ts → loader-iGq2PT_k.d.ts} +1 -1
- package/dist/{loader-DAZlgqeC.js → loader-r49nDwB4.js} +15 -0
- package/dist/net.d.ts +1 -1
- package/dist/net.js +3 -3
- package/dist/{pathfinding-B3QtdhvZ.d.ts → pathfinding-pPrqtlWS.d.ts} +1 -1
- package/dist/{physics-2d-B4v39hca.js → physics-2d-C8Hi40qb.js} +2 -2
- package/dist/{physics-3d-DFJVMYET.js → physics-3d-C0mWoWXC.js} +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-COGCNitr.js → register-DFSW2Y30.js} +2 -2
- package/dist/{register-BHAwM4bK.js → register-DOWGnxe1.js} +1 -1
- package/dist/{register-DXwtIVqP.js → register-t15rydSm.js} +2 -2
- package/dist/{audit-D7F3n3Nt.js → replay-BuOTl5rM.js} +72 -2
- package/dist/replay-DGzhZWrM.d.ts +86 -0
- package/dist/test-BiIO9ULW.js +1404 -0
- package/dist/test.d.ts +113 -4
- package/dist/test.js +3 -3
- package/dist/vite.js +1 -1
- package/editor/assets/{agent8-DMvBVhe4.js → agent8-CeeI7P_0.js} +1 -1
- package/editor/assets/{debug-BgAq55oC.js → debug-DN9X9G6z.js} +1 -1
- package/editor/assets/{index-Cy_QsKbh.js → index-C833FBzB.js} +42 -42
- package/editor/index.html +1 -1
- package/package.json +3 -1
- package/schemas/scene.schema.json +24 -0
- package/skills/incanto-3d-character.md +35 -0
- package/skills/incanto-building-2d-games.md +6 -0
- package/skills/incanto-building-3d-games.md +6 -0
- package/skills/incanto-game-feel.md +76 -0
- package/skills/incanto-node-reference.md +6 -0
- package/skills/incanto-playtesting.md +143 -0
- package/skills/incanto-verifying-your-game.md +41 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/village.scene.json +3 -1
- package/templates-app/village-quest-3d/verify.ts +55 -5
- package/dist/errors-DGRtWlSx.d.ts +0 -39
- package/dist/test-X_xE6Yay.js +0 -642
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-C833FBzB.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.37.0",
|
|
4
4
|
"description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"game-engine",
|
|
@@ -95,6 +95,8 @@
|
|
|
95
95
|
"incanto-assets": "bin/incanto-assets.mjs",
|
|
96
96
|
"incanto-env": "bin/incanto-env.mjs",
|
|
97
97
|
"incanto-play": "bin/incanto-play.mjs",
|
|
98
|
+
"incanto-playtest": "bin/incanto-playtest.mjs",
|
|
99
|
+
"incanto-feel": "bin/incanto-feel.mjs",
|
|
98
100
|
"incanto-new": "bin/incanto-new.mjs"
|
|
99
101
|
}
|
|
100
102
|
}
|
|
@@ -2054,6 +2054,30 @@
|
|
|
2054
2054
|
"type": "number",
|
|
2055
2055
|
"default": 1.2
|
|
2056
2056
|
},
|
|
2057
|
+
"coyoteSeconds": {
|
|
2058
|
+
"type": "number",
|
|
2059
|
+
"default": 0
|
|
2060
|
+
},
|
|
2061
|
+
"jumpBufferSeconds": {
|
|
2062
|
+
"type": "number",
|
|
2063
|
+
"default": 0
|
|
2064
|
+
},
|
|
2065
|
+
"jumpCutMultiplier": {
|
|
2066
|
+
"type": "number",
|
|
2067
|
+
"default": 1
|
|
2068
|
+
},
|
|
2069
|
+
"maxJumps": {
|
|
2070
|
+
"type": "number",
|
|
2071
|
+
"default": 1
|
|
2072
|
+
},
|
|
2073
|
+
"airControl": {
|
|
2074
|
+
"type": "number",
|
|
2075
|
+
"default": 0.2
|
|
2076
|
+
},
|
|
2077
|
+
"fallGravity": {
|
|
2078
|
+
"type": "number",
|
|
2079
|
+
"default": 2.5
|
|
2080
|
+
},
|
|
2057
2081
|
"floatHeight": {
|
|
2058
2082
|
"type": "number",
|
|
2059
2083
|
"default": 0.01
|
|
@@ -39,6 +39,41 @@ capsule BOTTOM `floatHeight` above the ground (5-ray probe). Author the Skin
|
|
|
39
39
|
y-offset as `-(halfHeight + radius + floatHeight)` so the feet sit at the
|
|
40
40
|
capsule bottom (the example templates do this).
|
|
41
41
|
|
|
42
|
+
## Game feel — the props a jump needs to stop feeling broken
|
|
43
|
+
|
|
44
|
+
A jump that only fires while the ground ray says so feels BROKEN, and players do
|
|
45
|
+
not report it as "the coyote time is missing" — they report the game as
|
|
46
|
+
unresponsive. `CharacterController2D` got these in 0.33.0; the 3D controller
|
|
47
|
+
did not until now.
|
|
48
|
+
|
|
49
|
+
| prop | default | what it buys |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `coyoteSeconds` | `0` | jump this long AFTER walking off a ledge. **The single biggest one** — try `0.12`. |
|
|
52
|
+
| `jumpBufferSeconds` | `0` | press jump this long BEFORE landing and still get it. Try `0.15`. |
|
|
53
|
+
| `jumpCutMultiplier` | `1` | release early and the rise is cut to this fraction. `0.45` = variable-height jump. |
|
|
54
|
+
| `maxJumps` | `1` | `2` = double jump. Extra jumps work in mid-air. |
|
|
55
|
+
| `airControl` | `0.2` | how much ground control you keep airborne (0 = committed, 1 = full). |
|
|
56
|
+
| `fallGravity` | `2.5` | gravity multiplier while falling. Higher = snappier arc. |
|
|
57
|
+
|
|
58
|
+
Every one defaults to OFF (`0` / `1`) or to the previous hard-coded constant, so
|
|
59
|
+
a scene that asks for nothing behaves exactly as before.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "name": "Ctl", "type": "CharacterController3D",
|
|
63
|
+
"props": { "coyoteSeconds": 0.12, "jumpBufferSeconds": 0.15,
|
|
64
|
+
"jumpCutMultiplier": 0.45, "maxJumps": 2 } }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Jump reads the button EDGE.** Holding the jump key no longer re-jumps every
|
|
68
|
+
frame — that auto-hop was never intended and it made coyote time incoherent (a
|
|
69
|
+
held button would re-fire through the whole window).
|
|
70
|
+
|
|
71
|
+
**Two ground senses, deliberately.** `grounded` stays generous (slack for the
|
|
72
|
+
hover spring, so slopes and bumps do not flicker the animation state). The feel
|
|
73
|
+
props and the airborne animation use a stricter test: close to the support AND
|
|
74
|
+
not moving upward. Without the velocity term the check is one frame stale, which
|
|
75
|
+
at launch speed is ~6 cm — enough to still read as standing on the floor.
|
|
76
|
+
|
|
42
77
|
## Views
|
|
43
78
|
|
|
44
79
|
| view | what it does | key props |
|
|
@@ -329,3 +329,9 @@ Wall moves need to know there IS a wall: `CharacterBody2D` now answers
|
|
|
329
329
|
`isOnFloor()`. Before, the floor was the only surface a character could report,
|
|
330
330
|
which is why a wall jump was not awkward to write but impossible.
|
|
331
331
|
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
**And then play it.** `bunx incanto-playtest src/game.scene.json` runs seeded
|
|
335
|
+
headless bot sessions and reports what could not be reached, which wires never
|
|
336
|
+
fired, and whether anything in the level can hurt the player. See
|
|
337
|
+
`incanto-playtesting.md`.
|
|
@@ -578,3 +578,9 @@ behind it), what lights the scene, and which colliders intersect. Then read
|
|
|
578
578
|
`stats().errors` (something threw and got skipped) and `assetErrors()` (a model
|
|
579
579
|
404'd). All four are silent failures otherwise — the screen just looks wrong,
|
|
580
580
|
or empty, and nothing throws.
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
**And then play it.** `bunx incanto-playtest src/game.scene.json` runs seeded
|
|
584
|
+
headless bot sessions and reports what could not be reached, which wires never
|
|
585
|
+
fired, and whether anything in the level can hurt the player. See
|
|
586
|
+
`incanto-playtesting.md`.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: incanto-game-feel
|
|
3
|
+
description: Measure what your controls actually feel like — bunx incanto-feel reports top speed, ramp and stop times, turnaround, jump apex and airtime, and the coyote/jump-buffer windows MEASURED by probing rather than read from the schema. Use when tuning a character, or when a game "feels off" and you cannot see why.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Game feel, as numbers
|
|
7
|
+
|
|
8
|
+
`capture()` and `framing()` are snapshots. Feel lives on the **time axis**.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bunx incanto-feel src/game.scene.json
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
feel of /Game/Player
|
|
16
|
+
|
|
17
|
+
top speed 5.62 u/s
|
|
18
|
+
to full speed 383 ms
|
|
19
|
+
to a stop 133 ms
|
|
20
|
+
turnaround 367 ms
|
|
21
|
+
input → motion 1 frame(s)
|
|
22
|
+
|
|
23
|
+
jump apex 0.262 u
|
|
24
|
+
time to apex 133 ms
|
|
25
|
+
airtime 267 ms
|
|
26
|
+
coyote window 100 ms
|
|
27
|
+
jump buffer 150 ms
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Why this exists
|
|
31
|
+
|
|
32
|
+
**`jumpVelocity: 4` is not a height.** It is an impulse, and what it produces
|
|
33
|
+
depends on gravity, mass, drag and `fallGravity`. The apex in world units and the
|
|
34
|
+
hang time in milliseconds are what you are actually choosing.
|
|
35
|
+
|
|
36
|
+
**The windows are MEASURED, not read.** `coyoteMs` comes from probing — lift the
|
|
37
|
+
character clear of its support, wait, press, see whether it launched, binary
|
|
38
|
+
search the boundary. A `coyoteSeconds: 0.12` that the controller never consults
|
|
39
|
+
reports **0 ms**. That was a real bug in this engine's own 3D controller until
|
|
40
|
+
0.37, and the JSON looked correct the entire time.
|
|
41
|
+
|
|
42
|
+
## Reading it
|
|
43
|
+
|
|
44
|
+
| line | what it tells you |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `to full speed` | acceleration. Under ~100 ms is twitchy-responsive; over ~500 ms is weighty. |
|
|
47
|
+
| `to a stop` | how much you slide. A platformer that overshoots ledges has a long one. |
|
|
48
|
+
| `turnaround` | the cost of changing your mind. The number players feel most and name least. |
|
|
49
|
+
| `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
|
+
| `coyote window` | **0 reads to players as "the controls are broken"**, not as a missing feature. |
|
|
52
|
+
| `jump buffer` | 0 throws away a press made a few frames before landing. |
|
|
53
|
+
|
|
54
|
+
## It reports, it does not grade
|
|
55
|
+
|
|
56
|
+
There are no PASS/FAIL bands here on purpose. A twitch platformer and a survival
|
|
57
|
+
horror want opposite numbers, and a table of thresholds invented by an engine
|
|
58
|
+
would be confidently wrong for most games. Two exceptions are called out because
|
|
59
|
+
they are almost never intentional: a coyote window of 0 and a jump buffer of 0.
|
|
60
|
+
|
|
61
|
+
## Tuning loop
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bunx incanto-feel src/game.scene.json # measure
|
|
65
|
+
# edit coyoteSeconds / jumpBufferSeconds / jumpVelocity / fallGravity
|
|
66
|
+
bunx incanto-feel src/game.scene.json # measure again
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The props live on `CharacterController2D` / `CharacterController3D` — see
|
|
70
|
+
`incanto-3d-character.md`.
|
|
71
|
+
|
|
72
|
+
## Pairs with the playtester
|
|
73
|
+
|
|
74
|
+
`incanto-feel` answers *how does it move*. `incanto-playtest` answers *can it be
|
|
75
|
+
finished*. A level whose jump apex is 0.26 u and whose gaps are 2 u apart will
|
|
76
|
+
show up in the second as `never reached`.
|
|
@@ -277,6 +277,12 @@ Signals: `movementStateChanged(state)`
|
|
|
277
277
|
| `sprintMultiplier` | `2` | number |
|
|
278
278
|
| `jumpVelocity` | `4` | number |
|
|
279
279
|
| `sprintJumpMultiplier` | `1.2` | number |
|
|
280
|
+
| `coyoteSeconds` | `0` | number |
|
|
281
|
+
| `jumpBufferSeconds` | `0` | number |
|
|
282
|
+
| `jumpCutMultiplier` | `1` | number |
|
|
283
|
+
| `maxJumps` | `1` | number |
|
|
284
|
+
| `airControl` | `0.2` | number |
|
|
285
|
+
| `fallGravity` | `2.5` | number |
|
|
280
286
|
| `floatHeight` | `0.01` | number |
|
|
281
287
|
| `mouseLook` | `true` | boolean |
|
|
282
288
|
| `zoomMin` | `0` | number |
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: incanto-playtesting
|
|
3
|
+
description: The engine plays your game and reports whether it works — bunx incanto-playtest runs seeded headless bot sessions and reports win rate, unreachable destinations, wires that never fire, falls out of the world, and whether anything can hurt the player. Failing runs come back as replays. Use after a level or game loop is in place, before handing the game back.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Playtesting — is it a game?
|
|
7
|
+
|
|
8
|
+
`incanto-check` answers *will it load*. This answers *is it a game*.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bunx incanto-playtest src/game.scene.json --runs 20 --seconds 60
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
20 runs × up to 60s
|
|
16
|
+
|
|
17
|
+
✓ reached "won" in 17/20 median 38s
|
|
18
|
+
✗ fell in 3/20 (last at y=-52.0)
|
|
19
|
+
|
|
20
|
+
never reached (1 of 6):
|
|
21
|
+
/Game/Vault/Chest
|
|
22
|
+
|
|
23
|
+
never fired (2 of 9):
|
|
24
|
+
/Game/Boss.died
|
|
25
|
+
/Game/QuestLog.completed
|
|
26
|
+
|
|
27
|
+
danger: the player took damage 0 times in 20 runs — nothing here can hurt you
|
|
28
|
+
|
|
29
|
+
replays: .incanto/playtest/fell-seed7.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Exits **1** when no run reached a win, so it gates CI.
|
|
33
|
+
|
|
34
|
+
## What it does
|
|
35
|
+
|
|
36
|
+
A bot plays your game, headlessly, with real physics and a seeded RNG. It reads
|
|
37
|
+
the scene's own `input{}` block for its controls, so it can play a game it has
|
|
38
|
+
never seen. It holds a heading for a while rather than flipping a coin per
|
|
39
|
+
frame, and steers toward the part of the world it has visited least — enough to
|
|
40
|
+
find a door, not enough to solve a puzzle.
|
|
41
|
+
|
|
42
|
+
No renderer is involved, so twenty minutes of simulated play takes a second or
|
|
43
|
+
two.
|
|
44
|
+
|
|
45
|
+
## What it reports, and where each answer comes from
|
|
46
|
+
|
|
47
|
+
| line | source |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `reached "won"` | `GameFlow` state `'won'`, or a `won` signal (e.g. `ScoreKeeper`) |
|
|
50
|
+
| `lost` | `GameFlow` `'gameover'`, a `lost` signal, or the player's `Health.died` |
|
|
51
|
+
| `fell` | the player's world Y dropped below `--fall-below` (default: 50 under the spawn) |
|
|
52
|
+
| `error` | `stats().errors` went above zero — a behavior threw |
|
|
53
|
+
| `stuck` | none of the above before the clock ran out |
|
|
54
|
+
| `never reached` | destinations the bot never came within 2 units of |
|
|
55
|
+
| `never fired` | signals a `connections[]` entry listens to that never happened |
|
|
56
|
+
| `danger` | how many times the player's `Health` emitted `damaged` |
|
|
57
|
+
|
|
58
|
+
**Nothing here is a guess about your design.** The scene already declares what
|
|
59
|
+
winning is; this only listens.
|
|
60
|
+
|
|
61
|
+
## Destinations are what YOU marked
|
|
62
|
+
|
|
63
|
+
A node counts as somewhere to go when it **runs a behavior**, **carries a
|
|
64
|
+
group**, or **is a trigger volume** (`Area2D`/`Area3D`). Decoration is silent —
|
|
65
|
+
keying off the tree alone reported 150 unreached nodes on one village scene and
|
|
66
|
+
138 of them were roof beams.
|
|
67
|
+
|
|
68
|
+
So: if you want the playtester to care about something, give it a group.
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{ "name": "Chest", "type": "MeshInstance3D", "groups": ["loot"] }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Reading the failures
|
|
75
|
+
|
|
76
|
+
**`never reached`** is the reachability question, and research on game QA puts
|
|
77
|
+
it as the single largest bug class: a place the level says you can go and the
|
|
78
|
+
geometry says you cannot. Check the jump height, the gap, the collider.
|
|
79
|
+
|
|
80
|
+
**`never fired`** means you drew a wire that does nothing. Either the signal is
|
|
81
|
+
unreachable or the connection is wrong.
|
|
82
|
+
|
|
83
|
+
**`nothing here can hurt you`** is a design report, not an error. Sometimes it
|
|
84
|
+
is exactly right (a walking simulator). Usually it means the hazards are not
|
|
85
|
+
wired up.
|
|
86
|
+
|
|
87
|
+
**`stuck` in every run** with no `won` at all usually means the scene declares
|
|
88
|
+
no win condition — add a `GameFlow` or a `ScoreKeeper` and the report becomes
|
|
89
|
+
far more useful.
|
|
90
|
+
|
|
91
|
+
## Replays
|
|
92
|
+
|
|
93
|
+
A run that did not win is written out. Because the engine is deterministic
|
|
94
|
+
(seeded RNG, injected clock), a replay reproduces that run **bit-identically**:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
bunx incanto-play src/game.scene.json --seed 7 # then replay the recording
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
One file per *kind* of failure — twenty identical "stuck" replays teach nothing
|
|
101
|
+
the first one does not.
|
|
102
|
+
|
|
103
|
+
## Your behaviors
|
|
104
|
+
|
|
105
|
+
Without `--behaviors`, unregistered scripts are stubbed: the structure plays and
|
|
106
|
+
your game logic does not, which is fine for a reachability pass. To test the
|
|
107
|
+
real thing:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
bunx incanto-playtest src/game.scene.json --behaviors src/behaviors.ts
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## From TypeScript
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { playtest, playtestText, failingReplays } from 'incanto/test';
|
|
117
|
+
|
|
118
|
+
const report = await playtest(sceneJson, { runs: 20, seconds: 60 });
|
|
119
|
+
console.log(playtestText(report));
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`report.runs` carries per-run outcome, timing, reached paths, fired signals,
|
|
123
|
+
damage and the replay.
|
|
124
|
+
|
|
125
|
+
## Pairs with the feel report
|
|
126
|
+
|
|
127
|
+
`incanto-playtest` answers *can it be finished*. `bunx incanto-feel` answers
|
|
128
|
+
*how does it move* — jump apex, turnaround, and the coyote/jump-buffer windows
|
|
129
|
+
measured rather than read. A level whose apex is 0.26 u and whose gaps are 2 u
|
|
130
|
+
apart shows up here as `never reached` and there as the reason why. See
|
|
131
|
+
`incanto-game-feel.md`.
|
|
132
|
+
|
|
133
|
+
## What it is NOT
|
|
134
|
+
|
|
135
|
+
It does not know whether your game is fun. It reports reachability,
|
|
136
|
+
completability, coverage and danger. Taste is still yours.
|
|
137
|
+
|
|
138
|
+
## Before you hand a game back
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
bunx incanto-check src/*.scene.json # will it load
|
|
142
|
+
bunx incanto-playtest src/game.scene.json --runs 10 --seconds 45 # is it a game
|
|
143
|
+
```
|
|
@@ -402,3 +402,44 @@ expect(player.position).toEqual(expected); // exact, not approximate
|
|
|
402
402
|
Rules that make it hold (the engine's own rules anyway): use `engine.rng`
|
|
403
403
|
never Math.random, dt/`engine.time` never Date.now. Gamepads replay through
|
|
404
404
|
the ACTIONS they were bound to, not raw pad state.
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
## The fifth question: is it a GAME?
|
|
408
|
+
|
|
409
|
+
The four signals above answer *will it load*, *did something throw*, *did the art
|
|
410
|
+
arrive* and *can the camera see it*. None of them answers whether the thing can
|
|
411
|
+
be played.
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
bunx incanto-playtest src/game.scene.json --runs 20 --seconds 60
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
A seeded bot plays it headlessly — reading the scene's own `input{}` for its
|
|
418
|
+
controls — and reports the win rate, destinations it could never reach, wires
|
|
419
|
+
that never fired, falls out of the world, and whether anything can hurt the
|
|
420
|
+
player at all. Failing runs come back as replays. Exits 1 when nothing ever won.
|
|
421
|
+
|
|
422
|
+
Read `incanto-playtesting.md` before shipping a level.
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
## Do not teleport the player in a verify script
|
|
426
|
+
|
|
427
|
+
`player.position = [...]` between beats proves the quest LOGIC and quietly
|
|
428
|
+
assumes the thing most likely to be broken: that the player can get there. That
|
|
429
|
+
assumption is the largest single bug class in level design, and a verify script
|
|
430
|
+
that makes it is testing everything except the level.
|
|
431
|
+
|
|
432
|
+
Walk instead. In a camera-relative rig (`view: "free"`), CALIBRATE rather than
|
|
433
|
+
guess the heading — push one direction, measure the world displacement it
|
|
434
|
+
produced, and rotate every later aim by the difference:
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
const before = at();
|
|
438
|
+
ctx.engine.input.setActionVector('move', 1, 0);
|
|
439
|
+
for (let i = 0; i < 20; i++) ctx.engine.step();
|
|
440
|
+
const inputToWorld = Math.atan2(at()[1] - before[1], at()[0] - before[0]);
|
|
441
|
+
// then aim: setActionVector('move', cos(want - inputToWorld), sin(want - inputToWorld))
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
`examples/village-quest-3d/verify.ts` does exactly this and fails with
|
|
445
|
+
"the player cannot reach it on foot" when it cannot.
|
|
@@ -26,6 +26,58 @@ interface Vec {
|
|
|
26
26
|
position: number[];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* WALK there — do not teleport.
|
|
31
|
+
*
|
|
32
|
+
* This file used to set `player.position` at every quest beat, which proved the
|
|
33
|
+
* quest LOGIC and quietly assumed the thing most likely to be broken: that the
|
|
34
|
+
* player can actually get there. `incanto-playtest` exists because that
|
|
35
|
+
* assumption is the largest bug class in level design, and a verify script that
|
|
36
|
+
* makes it is demonstrating the wrong habit in the repo's own showcase.
|
|
37
|
+
*
|
|
38
|
+
* `move` is camera-relative in the `free` rig, so the heading is CALIBRATED
|
|
39
|
+
* rather than assumed: push one direction, measure the world displacement it
|
|
40
|
+
* produced, and rotate every later aim by the difference. That works whatever
|
|
41
|
+
* the camera is doing and needs no knowledge of the rig.
|
|
42
|
+
*/
|
|
43
|
+
function walkTo(
|
|
44
|
+
ctx: { engine: Engine; getNode(path: string): unknown },
|
|
45
|
+
target: readonly [number, number],
|
|
46
|
+
opts: { within?: number; timeoutMs?: number; label?: string } = {},
|
|
47
|
+
): void {
|
|
48
|
+
const within = opts.within ?? 1.2;
|
|
49
|
+
const budget = Math.round((opts.timeoutMs ?? 20000) / (1000 / 60));
|
|
50
|
+
const player = ctx.getNode('/root/Player') as unknown as Vec;
|
|
51
|
+
const at = (): [number, number] => [player.position[0] ?? 0, player.position[2] ?? 0];
|
|
52
|
+
const dist = (): number => Math.hypot(target[0] - at()[0], target[1] - at()[1]);
|
|
53
|
+
|
|
54
|
+
// calibrate: push +x in INPUT space and see where the world went
|
|
55
|
+
const before = at();
|
|
56
|
+
ctx.engine.input.setActionVector('move', 1, 0);
|
|
57
|
+
for (let i = 0; i < 20; i++) ctx.engine.step();
|
|
58
|
+
const after = at();
|
|
59
|
+
ctx.engine.input.setActionVector('move', 0, 0);
|
|
60
|
+
const moved = Math.hypot(after[0] - before[0], after[1] - before[1]);
|
|
61
|
+
if (moved < 0.05) throw new Error(`walkTo(${opts.label ?? ''}): the player cannot move at all`);
|
|
62
|
+
const inputToWorld = Math.atan2(after[1] - before[1], after[0] - before[0]);
|
|
63
|
+
|
|
64
|
+
for (let f = 0; f < budget && dist() > within; f++) {
|
|
65
|
+
const here = at();
|
|
66
|
+
const want = Math.atan2(target[1] - here[1], target[0] - here[0]);
|
|
67
|
+
const aim = want - inputToWorld;
|
|
68
|
+
ctx.engine.input.setActionVector('move', Math.cos(aim), Math.sin(aim));
|
|
69
|
+
ctx.engine.step();
|
|
70
|
+
}
|
|
71
|
+
ctx.engine.input.setActionVector('move', 0, 0);
|
|
72
|
+
for (let i = 0; i < 10; i++) ctx.engine.step();
|
|
73
|
+
if (dist() > within) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`walkTo(${opts.label ?? `${target[0]},${target[1]}`}): still ${dist().toFixed(1)} away ` +
|
|
76
|
+
`after ${opts.timeoutMs ?? 20000}ms — the player cannot reach it on foot`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
29
81
|
const ok = (label: string, cond: boolean): void => {
|
|
30
82
|
if (!cond) {
|
|
31
83
|
console.error(`FAIL: ${label}`);
|
|
@@ -64,9 +116,8 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
64
116
|
label: 'talk to the Elder and accept the quest',
|
|
65
117
|
do: (ctx) => {
|
|
66
118
|
sceneName = () => ctx.engine.scene?.source?.name as string;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
for (let i = 0; i < 5; i++) ctx.engine.step();
|
|
119
|
+
// the Elder is at [3, 0, -2] with Interactable range 2.8
|
|
120
|
+
walkTo(ctx, [3, -2], { within: 2, label: 'the Elder' });
|
|
70
121
|
ctx.engine.input.pressAction('interact');
|
|
71
122
|
ctx.engine.step();
|
|
72
123
|
ctx.engine.input.releaseAction('interact');
|
|
@@ -85,8 +136,7 @@ const ok = (label: string, cond: boolean): void => {
|
|
|
85
136
|
atMs: 6000,
|
|
86
137
|
label: 'north gate swaps to the grove (goToScene)',
|
|
87
138
|
do: (ctx) => {
|
|
88
|
-
|
|
89
|
-
player.position = [0, 1.5, -26.4]; // into the gate trigger
|
|
139
|
+
walkTo(ctx, [0, -26.4], { label: 'the north gate', timeoutMs: 30000 });
|
|
90
140
|
for (let i = 0; i < 30 && sceneName() !== 'WolfGrove'; i++) ctx.engine.step();
|
|
91
141
|
if (sceneName() !== 'WolfGrove') throw new Error(`still in ${sceneName()}`);
|
|
92
142
|
},
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { s as JsonObject } from "./schema-CFeioQRE.js";
|
|
2
|
-
|
|
3
|
-
//#region src/core/audit.d.ts
|
|
4
|
-
/** Human-readable warnings (empty = clean). Pure JSON walk, no registry. */
|
|
5
|
-
declare function auditScene(scene: JsonObject): string[];
|
|
6
|
-
//#endregion
|
|
7
|
-
//#region src/core/errors.d.ts
|
|
8
|
-
/**
|
|
9
|
-
* Stable error codes for every hard failure in the engine.
|
|
10
|
-
*
|
|
11
|
-
* Incanto fails loudly with machine-readable codes and messages that list valid
|
|
12
|
-
* alternatives — AI agents self-correct on hard failures, not warnings.
|
|
13
|
-
*/
|
|
14
|
-
type IncantoErrorCode = "BAD_FORMAT" | "BAD_NODE_PATH" | "DANGLING_CONNECTION" | "DUPLICATE_BEHAVIOR" | "DUPLICATE_NODE_TYPE" | "DUPLICATE_UNIQUE_NAME" | "DUPLICATE_UID" | "NODE_NOT_FOUND" | "PROP_TYPE_MISMATCH" | "TREE_VIOLATION" | "UNKNOWN_ANIMATION" | "UNKNOWN_ASSET" | "UNKNOWN_CONSTANT" | "UNKNOWN_BEHAVIOR" | "UNKNOWN_HANDLER" | "UNKNOWN_NODE_TYPE" | "UNKNOWN_PROP" | "UNKNOWN_SIGNAL" | "UNRESOLVED_INSTANCE";
|
|
15
|
-
/**
|
|
16
|
-
* Structured context mirrored from the prose message — tools and agents parse
|
|
17
|
-
* THESE instead of regexing the message (which is for humans and may reword).
|
|
18
|
-
*/
|
|
19
|
-
interface IncantoErrorDetails {
|
|
20
|
-
/** Absolute path of the offending node in the scene being loaded ('/Root/Child'). */
|
|
21
|
-
readonly path?: string;
|
|
22
|
-
/** The offending node's uid, when the scene JSON declared one. */
|
|
23
|
-
readonly uid?: string;
|
|
24
|
-
/** The offending node's type name. */
|
|
25
|
-
readonly nodeType?: string;
|
|
26
|
-
/** The offending prop key. */
|
|
27
|
-
readonly prop?: string;
|
|
28
|
-
/** The offending signal name. */
|
|
29
|
-
readonly signal?: string;
|
|
30
|
-
/** The valid alternatives the message also lists — for programmatic self-correction. */
|
|
31
|
-
readonly validOptions?: readonly string[];
|
|
32
|
-
}
|
|
33
|
-
declare class IncantoError extends Error {
|
|
34
|
-
readonly code: IncantoErrorCode;
|
|
35
|
-
readonly details: IncantoErrorDetails;
|
|
36
|
-
constructor(code: IncantoErrorCode, message: string, details?: IncantoErrorDetails);
|
|
37
|
-
}
|
|
38
|
-
//#endregion
|
|
39
|
-
export { auditScene as i, IncantoErrorCode as n, IncantoErrorDetails as r, IncantoError as t };
|