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.
Files changed (55) hide show
  1. package/bin/incanto-feel.mjs +93 -0
  2. package/bin/incanto-playtest.mjs +136 -0
  3. package/dist/2d.d.ts +2 -2
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +14 -3
  6. package/dist/3d.js +4 -4
  7. package/dist/{behavior-DibCwrW7.d.ts → behavior-5o1EkbLD.d.ts} +11 -0
  8. package/dist/{create-game-D-StzrTj.js → create-game-DmGc-Mrn.js} +5 -5
  9. package/dist/{create-game-DnOu2aAa.js → create-game-QqrrK3yr.js} +5 -5
  10. package/dist/debug.d.ts +1 -1
  11. package/dist/{duplicate-Cvb1BSca.js → duplicate-CRtihGmC.js} +1 -1
  12. package/dist/{environment-presets-fK9oyrrR.js → environment-presets-DWHxLHM5.js} +75 -9
  13. package/dist/{gameplay-C0MUMSak.js → gameplay-CsJDUQh_.js} +2 -2
  14. package/dist/gameplay.d.ts +1 -1
  15. package/dist/gameplay.js +1 -1
  16. package/dist/index.d.ts +4 -50
  17. package/dist/index.js +5 -75
  18. package/dist/{loader-BqigZsfF.d.ts → loader-iGq2PT_k.d.ts} +1 -1
  19. package/dist/{loader-DAZlgqeC.js → loader-r49nDwB4.js} +15 -0
  20. package/dist/net.d.ts +1 -1
  21. package/dist/net.js +3 -3
  22. package/dist/{pathfinding-B3QtdhvZ.d.ts → pathfinding-pPrqtlWS.d.ts} +1 -1
  23. package/dist/{physics-2d-B4v39hca.js → physics-2d-C8Hi40qb.js} +2 -2
  24. package/dist/{physics-3d-DFJVMYET.js → physics-3d-C0mWoWXC.js} +3 -3
  25. package/dist/react.d.ts +1 -1
  26. package/dist/react.js +1 -1
  27. package/dist/{register-COGCNitr.js → register-DFSW2Y30.js} +2 -2
  28. package/dist/{register-BHAwM4bK.js → register-DOWGnxe1.js} +1 -1
  29. package/dist/{register-DXwtIVqP.js → register-t15rydSm.js} +2 -2
  30. package/dist/{audit-D7F3n3Nt.js → replay-BuOTl5rM.js} +72 -2
  31. package/dist/replay-DGzhZWrM.d.ts +86 -0
  32. package/dist/test-BiIO9ULW.js +1404 -0
  33. package/dist/test.d.ts +113 -4
  34. package/dist/test.js +3 -3
  35. package/dist/vite.js +1 -1
  36. package/editor/assets/{agent8-DMvBVhe4.js → agent8-CeeI7P_0.js} +1 -1
  37. package/editor/assets/{debug-BgAq55oC.js → debug-DN9X9G6z.js} +1 -1
  38. package/editor/assets/{index-Cy_QsKbh.js → index-C833FBzB.js} +42 -42
  39. package/editor/index.html +1 -1
  40. package/package.json +3 -1
  41. package/schemas/scene.schema.json +24 -0
  42. package/skills/incanto-3d-character.md +35 -0
  43. package/skills/incanto-building-2d-games.md +6 -0
  44. package/skills/incanto-building-3d-games.md +6 -0
  45. package/skills/incanto-game-feel.md +76 -0
  46. package/skills/incanto-node-reference.md +6 -0
  47. package/skills/incanto-playtesting.md +143 -0
  48. package/skills/incanto-verifying-your-game.md +41 -0
  49. package/templates-app/beacon-isle-3d/package.json +1 -1
  50. package/templates-app/tps-3d/package.json +1 -1
  51. package/templates-app/village-quest-3d/package.json +1 -1
  52. package/templates-app/village-quest-3d/src/village.scene.json +3 -1
  53. package/templates-app/village-quest-3d/verify.ts +55 -5
  54. package/dist/errors-DGRtWlSx.d.ts +0 -39
  55. 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-Cy_QsKbh.js"></script>
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.36.2",
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.
@@ -14,7 +14,7 @@
14
14
  "@dimforge/rapier2d-compat": "0.19.3",
15
15
  "@dimforge/rapier3d-compat": "0.19.3",
16
16
  "@pixiv/three-vrm": "^3.5.3",
17
- "incanto": "^0.36.2",
17
+ "incanto": "^0.37.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.36.2",
16
+ "incanto": "^0.37.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {
@@ -13,7 +13,7 @@
13
13
  "@dimforge/rapier2d-compat": "0.19.3",
14
14
  "@dimforge/rapier3d-compat": "0.19.3",
15
15
  "@pixiv/three-vrm": "^3.5.3",
16
- "incanto": "^0.36.2",
16
+ "incanto": "^0.37.0",
17
17
  "three": "^0.184.0"
18
18
  },
19
19
  "devDependencies": {
@@ -3334,7 +3334,9 @@
3334
3334
  "run": "$anims/run",
3335
3335
  "fastRun": "$anims/runFast",
3336
3336
  "airborne": "$anims/jump"
3337
- }
3337
+ },
3338
+ "coyoteSeconds": 0.12,
3339
+ "jumpBufferSeconds": 0.15
3338
3340
  },
3339
3341
  "script": {
3340
3342
  "name": "SwordStrike"
@@ -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
- const player = ctx.getNode('/root/Player') as unknown as Vec;
68
- player.position = [3, 1.4, 0.2]; // step up to the Elder
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
- const player = ctx.getNode('/root/Player') as unknown as Vec;
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 };