incanto 0.36.2 → 0.38.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 +44 -3
- package/dist/3d.js +4 -32
- package/dist/{behavior-DibCwrW7.d.ts → behavior-B5PJ32eu.d.ts} +106 -6
- package/dist/{create-game-D-StzrTj.js → create-game-C4t5gVhc.js} +5 -5
- package/dist/{create-game-DnOu2aAa.js → create-game-DqY2lMBc.js} +783 -689
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-Cvb1BSca.js → duplicate-CRtihGmC.js} +1 -1
- package/dist/editor.js +21 -0
- package/dist/{environment-presets-fK9oyrrR.js → environment-presets-C0Axpfth.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-DSMdqasu.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-BrE5Uec6.d.ts} +1 -1
- package/dist/{physics-2d-B4v39hca.js → physics-2d-OfKb0PV_.js} +2 -2
- package/dist/{physics-3d-DFJVMYET.js → physics-3d-C8242nXr.js} +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-DXwtIVqP.js → register-B4RRQbyq.js} +2 -2
- package/dist/{register-BHAwM4bK.js → register-Dm7nrd-E.js} +322 -34
- package/dist/{register-COGCNitr.js → register-DnB9m7Rl.js} +2 -2
- package/dist/replay-Cxrr-ApH.d.ts +86 -0
- package/dist/{audit-D7F3n3Nt.js → replay-DH0UOuZ-.js} +72 -2
- package/dist/test-CoDPWx-m.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-BtIlblPm.js} +1 -1
- package/editor/assets/{debug-BgAq55oC.js → debug-BvTmHXI7.js} +1 -1
- package/editor/assets/{index-Cy_QsKbh.js → index-BS01WWgM.js} +91 -91
- package/editor/index.html +1 -1
- package/package.json +3 -1
- package/schemas/scene.schema.json +318 -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 +51 -0
- package/skills/incanto-performance.md +212 -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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: incanto-performance
|
|
3
|
+
description: Graphics settings the engine provides so a game does not have to invent them — quality tier (shadows/bloom/post/clouds), frame cap, resolution scale, and the one lever (antialiasing) that can only change at boot. They persist, they apply themselves, and the ones that can change live do. Use when supporting low-end devices or building a settings menu.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# incanto — performance settings (quality tier, frame cap, resolution)
|
|
7
|
+
|
|
8
|
+
The settings a player expects to find in a graphics menu, provided by the engine so
|
|
9
|
+
your game does not have to invent them: **quality tier**, **frame cap**, **resolution
|
|
10
|
+
scale**. They persist, they apply themselves, and the ones that can change live do.
|
|
11
|
+
|
|
12
|
+
You do not implement any of this. You decide whether to expose it.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## The short version
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const game = await createGame3D({ canvas, scene });
|
|
20
|
+
|
|
21
|
+
// createGame3D has ALREADY done all of this:
|
|
22
|
+
// - picked a starting tier from the device, on a first visit only
|
|
23
|
+
// - read the saved tier at boot for antialiasing (see "boot vs live" below)
|
|
24
|
+
// - bound the tier, the frame cap and the resolution scale to the live game
|
|
25
|
+
|
|
26
|
+
game.engine.settings.set('quality', 'low'); // shadows/bloom/post drop THIS FRAME
|
|
27
|
+
game.engine.settings.set('maxFps', 30); // the loop slows down, and stays cool
|
|
28
|
+
game.engine.settings.set('renderScale', 0.7); // fewer pixels, same world
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every one of those is written to the save store and comes back on the next launch.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## The four levers, and what each actually costs
|
|
36
|
+
|
|
37
|
+
| setting | values | costs | changes live |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `quality` | `'low'` `'medium'` `'high'` | shadows, bloom, post, clouds | **yes** |
|
|
40
|
+
| `maxFps` | `0` (uncapped) or fps | nothing visual — battery and heat | **yes** |
|
|
41
|
+
| `renderScale` | `0.25`–`2` | image softness only | **yes** |
|
|
42
|
+
| antialiasing | follows the tier | jagged edges at `low` | **no — boot only** |
|
|
43
|
+
|
|
44
|
+
### quality
|
|
45
|
+
|
|
46
|
+
`low` turns off shadows, bloom, post and clouds. `medium` keeps shadows but makes them
|
|
47
|
+
**static** (they render once instead of every frame — the measured 42% of a shadow's
|
|
48
|
+
cost is re-rendering it) and drops post. `high` is everything.
|
|
49
|
+
|
|
50
|
+
The tier is applied as an environment patch, so a scene that never had bloom does not
|
|
51
|
+
gain any at `high` — the tier can only take things away or restore what the scene asked
|
|
52
|
+
for.
|
|
53
|
+
|
|
54
|
+
### maxFps
|
|
55
|
+
|
|
56
|
+
Gates the **whole frame**, not just the render: an uncapped loop on a 144 Hz phone runs
|
|
57
|
+
game logic 144 times a second that nobody will ever see. `0` means "as fast as the
|
|
58
|
+
display goes" and is the default — a cap nobody asked for is a downgrade.
|
|
59
|
+
|
|
60
|
+
`dt` still comes from the clock, so a capped game is not a slow game. Physics is fixed-
|
|
61
|
+
step and unaffected.
|
|
62
|
+
|
|
63
|
+
### renderScale
|
|
64
|
+
|
|
65
|
+
The cheapest lever on a weak GPU. At `0.66` the fragment shaders run over **44%** of the
|
|
66
|
+
pixels; the image goes soft and nothing else changes. Compare that with dropping a
|
|
67
|
+
quality tier, which costs the shadows outright. On a phone that cannot hold 60, try
|
|
68
|
+
`renderScale` before `quality`.
|
|
69
|
+
|
|
70
|
+
It multiplies with the engine's automatic resolution governor rather than fighting it,
|
|
71
|
+
so a player who already scaled down does not get scaled down twice.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Boot vs live — the one thing that cannot change while running
|
|
76
|
+
|
|
77
|
+
`antialias` is a **WebGL context attribute**. three fixes it when the context is
|
|
78
|
+
created, and there is no API anywhere that changes it afterwards. So:
|
|
79
|
+
|
|
80
|
+
- the tier's antialiasing is read **at boot** from the saved setting
|
|
81
|
+
- changing `quality` mid-game changes shadows/bloom/post immediately, and the
|
|
82
|
+
antialiasing on the **next launch**
|
|
83
|
+
|
|
84
|
+
If your settings menu wants to be honest about that, say "applies on restart" next to
|
|
85
|
+
antialiasing only. Everything else is immediate.
|
|
86
|
+
|
|
87
|
+
A scene's own `environment.rendering` and an explicit `pixelRatio` option both still win
|
|
88
|
+
over the tier — an author who pinned a value meant it.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Detection runs once, and a choice is permanent
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
engine.settings.autoQuality(); // first visit only — picks from the device
|
|
96
|
+
engine.settings.chooseQuality('high'); // a HUMAN chose; never auto-detect again
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`createGame3D` calls `autoQuality()` for you. Call `chooseQuality(tier)` — not
|
|
100
|
+
`set('quality', tier)` — from a settings menu, so re-detection on the next launch does
|
|
101
|
+
not silently undo it. (A separate `qualityChosen` flag is needed because a chosen tier
|
|
102
|
+
that happens to equal the detected one is otherwise indistinguishable from no choice.)
|
|
103
|
+
|
|
104
|
+
Device hints used: `hardwareConcurrency`, `deviceMemory`, and whether the pointer is
|
|
105
|
+
coarse. They are hints; a player's own choice always wins.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## The menu itself is three nodes
|
|
110
|
+
|
|
111
|
+
You do not build a graphics menu. You place it.
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "name": "Graphics", "type": "UiPanel", "props": { "anchor": "center" },
|
|
115
|
+
"children": [
|
|
116
|
+
{ "name": "Quality", "type": "UiQualitySelect" },
|
|
117
|
+
{ "name": "FrameCap", "type": "UiFrameCapSelect" },
|
|
118
|
+
{ "name": "Resolution", "type": "UiRenderScaleSelect" }
|
|
119
|
+
] }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
No script, no signal connections, no `Settings` knowledge. Each node reads the
|
|
123
|
+
setting it owns, writes it when the player picks, and follows a change made
|
|
124
|
+
anywhere else — so two menus (a title screen and a pause screen) can never
|
|
125
|
+
disagree.
|
|
126
|
+
|
|
127
|
+
| node | writes | reads as |
|
|
128
|
+
| --- | --- | --- |
|
|
129
|
+
| `UiQualitySelect` | `chooseQuality()` | Low / Medium / High |
|
|
130
|
+
| `UiFrameCapSelect` | `maxFps` + `engine.maxFps` | 30 / 60 / 120 / Unlimited |
|
|
131
|
+
| `UiRenderScaleSelect` | `renderScale` | 50% / 75% / 100% |
|
|
132
|
+
|
|
133
|
+
`UiQualitySelect` goes through **`chooseQuality`**, not `set('quality')`, so the
|
|
134
|
+
next launch's device detection cannot undo what a person picked.
|
|
135
|
+
|
|
136
|
+
Restrict or extend the choices with `options` — `"60,120,0"` on a game that is
|
|
137
|
+
unplayable at 30, `"0.5,0.75,1,1.5"` for supersampling on a strong GPU:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{ "name": "FrameCap", "type": "UiFrameCapSelect", "props": { "options": "60,120,0" } }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Localizing the labels
|
|
144
|
+
|
|
145
|
+
The option text is English unless the scene declares a string for it — a menu
|
|
146
|
+
reading `settings.quality.high` is worse than one reading `High`, so an
|
|
147
|
+
undeclared key falls back to English rather than showing the key:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
"strings": {
|
|
151
|
+
"ko": {
|
|
152
|
+
"settings.quality.low": "낮음",
|
|
153
|
+
"settings.quality.medium": "보통",
|
|
154
|
+
"settings.quality.high": "높음",
|
|
155
|
+
"settings.frameCap.unlimited": "제한 없음"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The `label` prop takes `@t:` like every other widget:
|
|
161
|
+
`{ "label": "@t:settings.quality" }`.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Wiring your own menu
|
|
166
|
+
|
|
167
|
+
Each setting has a `bind*` that applies the saved value immediately and again on every
|
|
168
|
+
change, and returns an unsubscribe:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const off = engine.settings.bindFrameCap((fps) => { engine.maxFps = fps; });
|
|
172
|
+
engine.settings.bindRenderScale((s) => renderer.setRenderScale(s));
|
|
173
|
+
engine.settings.bindQuality((patch) => setEnvironment3D(engine, patch));
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`createGame3D` already installs all three. You only reach for these when you build your
|
|
177
|
+
own game loop.
|
|
178
|
+
|
|
179
|
+
To read the current values for your UI:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
engine.settings.get('quality'); // 'low' | 'medium' | 'high'
|
|
183
|
+
engine.settings.get('maxFps'); // 0 = uncapped
|
|
184
|
+
engine.settings.get('renderScale'); // 1 = native
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## What NOT to do
|
|
190
|
+
|
|
191
|
+
- **Do not build your own frame limiter** with `setTimeout` around your update. The
|
|
192
|
+
engine's cap is deadline-based; a naive `if (now - last < interval) return` snaps a
|
|
193
|
+
45 fps request to 30 on a 60 Hz display, which is worse than not capping.
|
|
194
|
+
- **Do not lower the quality tier to fix a slow scene you can fix properly.** The tier
|
|
195
|
+
is the player's lever. If your game is slow at `high` on a normal machine, run
|
|
196
|
+
`bunx incanto-check` and look at the triangle and draw-call counts first.
|
|
197
|
+
- **Do not offer antialiasing as a live toggle.** It cannot be one. See above.
|
|
198
|
+
- **Do not read `devicePixelRatio` yourself.** The renderer already clamps it to 2 and
|
|
199
|
+
scales it by the tier and the governor.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Verifying
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const { fps, triangles, drawCalls } = game.stats();
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Set `maxFps` to 30 and confirm `fps` settles near 30 — if it settles near 20, the game
|
|
210
|
+
is slower than the cap and the cap is not what is limiting it.
|
|
211
|
+
|
|
212
|
+
See `incanto-verifying-your-game.md` for the full loop.
|
|
@@ -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 };
|