incanto 0.28.0 → 0.30.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/README.md +44 -0
- package/bin/incanto-editor.mjs +219 -29
- package/dist/2d.d.ts +54 -6
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +353 -16
- package/dist/3d.js +5 -5
- package/dist/{behavior-CPibUfnH.d.ts → behavior-BAc0erXF.d.ts} +21 -0
- package/dist/{create-game-71TJjW1T.js → create-game-5z_QVtLx.js} +59 -16
- package/dist/{create-game-BRgWpNsa.js → create-game-DuBTv2zI.js} +276 -49
- package/dist/debug-draw-BM3DsvtT.js +18 -0
- package/dist/debug.d.ts +51 -6
- package/dist/debug.js +242 -19
- package/dist/editor-switch-B0wB_DSr.d.ts +59 -0
- package/dist/editor.d.ts +49 -0
- package/dist/editor.js +8554 -0
- package/dist/{gameplay-DEG-TP7D.js → gameplay-Cfr6aFZ1.js} +219 -40
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +31 -3
- package/dist/index.js +2 -2
- package/dist/{loader-DILt9PGC.d.ts → loader-B242FF6N.d.ts} +1 -1
- package/dist/net.d.ts +1 -1
- package/dist/net.js +2 -2
- package/dist/{pathfinding-RWYkNKx9.d.ts → pathfinding-BwD974Ss.d.ts} +1 -1
- package/dist/{physics-2d-DiVFFlH3.js → physics-2d-3kOQCtgd.js} +88 -3
- package/dist/{physics-3d--y5clE2j.js → physics-3d-CeRH-Ff_.js} +475 -9
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-nObreUQR.js → register-BTg0EM7s.js} +35 -3
- package/dist/{register-6R75AC7-.js → register-DJ0SByQg.js} +5550 -5073
- package/dist/{register-CvpSUU3O.js → register-DWcWq4QG.js} +22 -2
- package/dist/{register-BFFE1Mh1.js → register-MelqEdza.js} +1 -1
- package/dist/teardown-ByzfDPyu.js +240 -0
- package/dist/test.d.ts +35 -3
- package/dist/test.js +104 -10
- package/dist/vite.d.ts +139 -1
- package/dist/vite.js +355 -4
- package/editor/assets/{agent8-CojUfCXN.js → agent8-CAp0i5qn.js} +1 -1
- package/editor/assets/debug-BoEYfbqK.js +2 -0
- package/editor/assets/index-BO6WU8by.js +10696 -0
- package/editor/index.html +3 -157
- package/package.json +3 -2
- package/schemas/scene.schema.json +102 -4
- package/skills/incanto-3d-models.md +38 -0
- package/skills/incanto-assets.md +13 -0
- package/skills/incanto-building-3d-games.md +83 -6
- package/skills/incanto-editor.md +212 -8
- package/skills/incanto-environment.md +65 -1
- package/skills/incanto-node-reference.md +33 -1
- package/skills/incanto-physics-and-input.md +60 -6
- package/skills/incanto-verifying-your-game.md +57 -2
- package/templates-app/beacon-isle-3d/docs/project-3d-rules.md +5 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/vite.config.ts +7 -0
- package/templates-app/tps-3d/docs/project-3d-rules.md +5 -0
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/vite.config.ts +7 -0
- package/templates-app/village-quest-3d/docs/project-3d-rules.md +5 -0
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/vite.config.ts +7 -0
- package/dist/debug-draw-CZmOYjL2.js +0 -13
- package/editor/assets/index-D6RQgROR.js +0 -8330
- package/editor/assets/index-D8QvwvOm.css +0 -1
package/README.md
CHANGED
|
@@ -53,6 +53,50 @@ engine.start();
|
|
|
53
53
|
- `schemas/scene.schema.json` — generated JSON Schema for scene files (the agent contract)
|
|
54
54
|
- `templates/agent8-server.js` — the multiplayer server kernel for the agent8 platform
|
|
55
55
|
|
|
56
|
+
## Debug mode
|
|
57
|
+
|
|
58
|
+
Off by default, with **no URL toggle** — a deployed build can never be switched on by a player.
|
|
59
|
+
Gate it to your own dev server (every template already ships the line):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
createGame3D({ ..., debug: import.meta.env.VITE_INCANTO_DEBUG === '1' });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
VITE_INCANTO_DEBUG=1 bun run dev # overlay ON; plain `bun run dev` and prod builds = off
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A **☰ debug** menu appears top-left: **Explorer** (live scene tree; the selected node's bounds
|
|
70
|
+
light up orange in-game), **Inspector** (its props, editable while running), **Logs**
|
|
71
|
+
(`engine.log` tail with filters), **Stats** (fps · frame ms · nodes · tris · draw calls), and
|
|
72
|
+
**Colliders** (physics shapes as wireframe; one click cycles off → all → **selected**, and
|
|
73
|
+
`selected` draws only the collider of whatever the Explorer has picked — the way to find one shape
|
|
74
|
+
in a world full of them). In code: `physics.debugDraw = true` + `physics.debugScope = node`, or
|
|
75
|
+
`(await import('incanto/debug')).attachDebugOverlay(engine)` to boot the overlay by hand.
|
|
76
|
+
|
|
77
|
+
## The agent8 asset library
|
|
78
|
+
|
|
79
|
+
`bunx incanto-editor --token <v8 access token>` (or `INCANTO_V8_TOKEN`) adds a
|
|
80
|
+
**📚** button to every field that takes a resource — model, texture, sprite
|
|
81
|
+
sheet, sound. Shelves, search, real previews (a GLB is rendered by the engine
|
|
82
|
+
itself), and one pick writes the URL, or the scene `assets{}` entry plus its
|
|
83
|
+
`$ref` for fields that take one. Without a token the panel asks for one and
|
|
84
|
+
keeps it in the browser; the editor server proxies the catalog because it is
|
|
85
|
+
per-account and not CORS-open to localhost.
|
|
86
|
+
|
|
87
|
+
## Play ⇄ edit, in one window
|
|
88
|
+
|
|
89
|
+
The menu's last item, **✎ edit this scene**, turns the running game into the scene editor in the
|
|
90
|
+
page you are already in — same window, no reload, and the edit camera starts where the player's
|
|
91
|
+
eye was. The editor's **▶ play** offers the way back as a choice: **preview here** (simulate in
|
|
92
|
+
the viewport, scripts stripped) or **run as the game** (this page becomes the real game again,
|
|
93
|
+
with your edits). **✕ exit** returns without playing.
|
|
94
|
+
|
|
95
|
+
It follows `debug`, so an overlay build already has it. `editor: false` opts out;
|
|
96
|
+
`editor: { save }` gives the editor a save button that writes your file; `game.openEditor()` is
|
|
97
|
+
the same switch as a function. The editor loads lazily (`incanto/editor`) the first time it is
|
|
98
|
+
asked for — a game that never opens it never downloads it.
|
|
99
|
+
|
|
56
100
|
Docs, agent skills, examples, and architecture: **https://github.com/rareboe/Incanto**
|
|
57
101
|
|
|
58
102
|
© 2026 Verse8. All rights reserved. Proprietary — see [LICENSE](./LICENSE). Bundled third-party OSS notices: [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md).
|
package/bin/incanto-editor.mjs
CHANGED
|
@@ -9,17 +9,25 @@
|
|
|
9
9
|
*
|
|
10
10
|
* API (the page can only address *.scene.json files under the launch directory):
|
|
11
11
|
* GET /api/meta → { version, mode, root, input?, output? }
|
|
12
|
-
* GET /api/scenes → [{ rel, abs }]
|
|
12
|
+
* GET /api/scenes → [{ rel, abs, size, modified, scene?|notScene? }]
|
|
13
13
|
* POST /api/scenes → { path } creates a minimal scene (project mode)
|
|
14
14
|
* GET /api/scene[?file=] → scene JSON (single mode ignores ?file=)
|
|
15
15
|
* PUT /api/scene[?file=] → validates the scene shape, writes the file
|
|
16
|
+
* GET /api/library?tags=… → proxies the agent8 asset catalog (see below)
|
|
17
|
+
*
|
|
18
|
+
* The library proxy exists because the catalog cannot be reached from a page:
|
|
19
|
+
* it wants a Verse8 access token and its CORS allow-list has no localhost in
|
|
20
|
+
* it. This server has neither problem. The token comes from --token, from
|
|
21
|
+
* INCANTO_V8_TOKEN / V8_ACCESS_TOKEN, or from the page's own
|
|
22
|
+
* `x-incanto-v8-token` header (what the browser stores when you paste one) —
|
|
23
|
+
* it is forwarded upstream and never written to disk.
|
|
16
24
|
*
|
|
17
25
|
* Security: on loopback binds, the Host header must be loopback (defeats DNS
|
|
18
26
|
* rebinding); writes additionally require Origin (when present) to match the
|
|
19
27
|
* request host. Bodies are capped at 8MB. Addressable paths are root-bounded
|
|
20
28
|
* and must end in .scene.json.
|
|
21
29
|
*/
|
|
22
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
23
31
|
import { createServer } from 'node:http';
|
|
24
32
|
import { basename, dirname, extname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
25
33
|
import { fileURLToPath } from 'node:url';
|
|
@@ -60,6 +68,7 @@ function parseArgs(argv) {
|
|
|
60
68
|
for (let i = 0; i < argv.length; i++) {
|
|
61
69
|
const a = argv[i];
|
|
62
70
|
if (a === '--output') args.output = argv[++i];
|
|
71
|
+
else if (a === '--token') args.token = argv[++i];
|
|
63
72
|
else if (a === '--port') args.port = Number(argv[++i]);
|
|
64
73
|
else if (a === '--host') args.host = argv[++i];
|
|
65
74
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
@@ -79,7 +88,11 @@ if (args.help || args.error || Number.isNaN(args.port) || (args.output && !args.
|
|
|
79
88
|
Without a file: PROJECT mode — discovers every *.scene.json under the current
|
|
80
89
|
directory (a picker opens; a single scene auto-opens; new scenes can be created).
|
|
81
90
|
With a file: SINGLE mode — that file only; saving writes to --output (default:
|
|
82
|
-
the input file). --output requires an input file. Use --host 0.0.0.0 inside containers
|
|
91
|
+
the input file). --output requires an input file. Use --host 0.0.0.0 inside containers.
|
|
92
|
+
|
|
93
|
+
--token <v8 access token> (or INCANTO_V8_TOKEN) unlocks the agent8 asset library
|
|
94
|
+
inside the editor. Without it the 📚 button asks for a token and remembers it in
|
|
95
|
+
the browser instead.`);
|
|
83
96
|
process.exit(args.help && !args.error ? 0 : 1);
|
|
84
97
|
}
|
|
85
98
|
|
|
@@ -91,6 +104,102 @@ const VERSION = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')
|
|
|
91
104
|
|
|
92
105
|
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage']);
|
|
93
106
|
|
|
107
|
+
// --- agent8 asset library -------------------------------------------------
|
|
108
|
+
const LIBRARY_URL =
|
|
109
|
+
process.env.INCANTO_LIBRARY_URL ?? 'https://agent8-backend.verse8.io/api/resources';
|
|
110
|
+
const ENV_TOKEN = args.token ?? process.env.INCANTO_V8_TOKEN ?? process.env.V8_ACCESS_TOKEN ?? '';
|
|
111
|
+
const LIBRARY_TIMEOUT_MS = 15_000;
|
|
112
|
+
|
|
113
|
+
/** Upstream row → the shape the editor consumes (ids are strings here). */
|
|
114
|
+
function libraryItem(row) {
|
|
115
|
+
return {
|
|
116
|
+
id: String(row?.id ?? row?.url ?? ''),
|
|
117
|
+
url: String(row?.url ?? ''),
|
|
118
|
+
description: typeof row?.description === 'string' ? row.description : '',
|
|
119
|
+
metadata: row?.metadata && typeof row.metadata === 'object' ? row.metadata : {},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function proxyLibrary(req, res, url) {
|
|
124
|
+
// the page's token wins over the server's: a user who pasted one is telling
|
|
125
|
+
// us which account to search as
|
|
126
|
+
const token = (req.headers['x-incanto-v8-token'] || '').toString().trim() || ENV_TOKEN;
|
|
127
|
+
if (!token) {
|
|
128
|
+
return send(
|
|
129
|
+
res,
|
|
130
|
+
401,
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
error:
|
|
133
|
+
'no Verse8 access token. Start the editor with --token <t> (or INCANTO_V8_TOKEN), or paste one into the library panel.',
|
|
134
|
+
needsToken: true,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const params = new URLSearchParams({
|
|
139
|
+
tags: url.searchParams.get('tags') ?? '',
|
|
140
|
+
page: url.searchParams.get('page') ?? '1',
|
|
141
|
+
limit: url.searchParams.get('limit') ?? '24',
|
|
142
|
+
});
|
|
143
|
+
const keyword = url.searchParams.get('keyword');
|
|
144
|
+
if (keyword) params.set('keyword', keyword);
|
|
145
|
+
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const timer = setTimeout(() => controller.abort(), LIBRARY_TIMEOUT_MS);
|
|
148
|
+
try {
|
|
149
|
+
const upstream = await fetch(`${LIBRARY_URL}?${params}`, {
|
|
150
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/json' },
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
});
|
|
153
|
+
const text = await upstream.text();
|
|
154
|
+
if (upstream.status === 401 || upstream.status === 403) {
|
|
155
|
+
return send(
|
|
156
|
+
res,
|
|
157
|
+
401,
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
error: 'the asset library rejected that token (expired or wrong account)',
|
|
160
|
+
needsToken: true,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (!upstream.ok) {
|
|
165
|
+
return send(res, 502, JSON.stringify({ error: `asset library: HTTP ${upstream.status}` }));
|
|
166
|
+
}
|
|
167
|
+
let body;
|
|
168
|
+
try {
|
|
169
|
+
body = JSON.parse(text);
|
|
170
|
+
} catch {
|
|
171
|
+
return send(res, 502, JSON.stringify({ error: 'asset library returned non-JSON' }));
|
|
172
|
+
}
|
|
173
|
+
if (body?.success === false) {
|
|
174
|
+
return send(res, 502, JSON.stringify({ error: String(body.error ?? 'asset library error') }));
|
|
175
|
+
}
|
|
176
|
+
const rows = Array.isArray(body?.data) ? body.data : [];
|
|
177
|
+
return send(
|
|
178
|
+
res,
|
|
179
|
+
200,
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
items: rows.map(libraryItem).filter((i) => i.url),
|
|
182
|
+
page: Number(body?.pagination?.page ?? params.get('page') ?? 1),
|
|
183
|
+
hasNext: body?.pagination?.hasNext === true,
|
|
184
|
+
total: typeof body?.pagination?.total === 'number' ? body.pagination.total : undefined,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const aborted = error?.name === 'AbortError';
|
|
189
|
+
return send(
|
|
190
|
+
res,
|
|
191
|
+
502,
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
error: aborted
|
|
194
|
+
? `asset library timed out after ${LIBRARY_TIMEOUT_MS / 1000}s`
|
|
195
|
+
: `asset library unreachable: ${String(error?.message ?? error)}`,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
94
203
|
function discoverScenes() {
|
|
95
204
|
const found = [];
|
|
96
205
|
const walk = (dir, depth) => {
|
|
@@ -104,9 +213,20 @@ function discoverScenes() {
|
|
|
104
213
|
for (const e of entries) {
|
|
105
214
|
if (e.isDirectory()) {
|
|
106
215
|
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), depth + 1);
|
|
107
|
-
} else if (e.name.endsWith('.
|
|
216
|
+
} else if (e.name.endsWith('.json')) {
|
|
108
217
|
const abs = join(dir, e.name);
|
|
109
|
-
|
|
218
|
+
// size + mtime are what the browser prints beside each row; a folder
|
|
219
|
+
// whose files are all "just now" is the one you were working in
|
|
220
|
+
let size = 0;
|
|
221
|
+
let modified = 0;
|
|
222
|
+
try {
|
|
223
|
+
const stat = statSync(abs);
|
|
224
|
+
size = stat.size;
|
|
225
|
+
modified = stat.mtimeMs;
|
|
226
|
+
} catch {
|
|
227
|
+
// listed without facts beats not listed at all
|
|
228
|
+
}
|
|
229
|
+
found.push({ rel: relative(ROOT, abs), abs, size, modified, ...inspectJson(abs, size) });
|
|
110
230
|
}
|
|
111
231
|
}
|
|
112
232
|
};
|
|
@@ -114,9 +234,65 @@ function discoverScenes() {
|
|
|
114
234
|
return found.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
115
235
|
}
|
|
116
236
|
|
|
117
|
-
/**
|
|
237
|
+
/**
|
|
238
|
+
* Is this JSON a scene? The file says so itself — `type: "scene"`, the same key
|
|
239
|
+
* the loader demands — so a row can be marked instead of guessed from its name.
|
|
240
|
+
*/
|
|
241
|
+
function sceneFacts(json) {
|
|
242
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
|
|
243
|
+
return { ok: false, why: 'the file is JSON, but not a JSON object' };
|
|
244
|
+
}
|
|
245
|
+
if (json.type !== 'scene') {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
why:
|
|
249
|
+
json.type === undefined
|
|
250
|
+
? '"type" must be "scene", and this file has no "type"'
|
|
251
|
+
: `"type" must be "scene", not ${JSON.stringify(json.type)}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (json.format !== 1)
|
|
255
|
+
return { ok: false, why: `"format" must be 1, not ${JSON.stringify(json.format)}` };
|
|
256
|
+
if (typeof json.root !== 'object' || json.root === null) {
|
|
257
|
+
return { ok: false, why: '"root" must be a node object' };
|
|
258
|
+
}
|
|
259
|
+
const count = (n) =>
|
|
260
|
+
Array.isArray(n?.children) ? n.children.reduce((t, c) => t + count(c), 1) : 1;
|
|
261
|
+
return {
|
|
262
|
+
ok: true,
|
|
263
|
+
name: typeof json.name === 'string' ? json.name : '',
|
|
264
|
+
// only when the file SAYS so — `dimension` is optional and has no default
|
|
265
|
+
...(json.dimension === '2d' || json.dimension === '3d' ? { dimension: json.dimension } : {}),
|
|
266
|
+
nodes: count(json.root),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function inspectJson(abs, size) {
|
|
271
|
+
if (size > 16_000_000) return { notScene: 'too large to check without opening it' };
|
|
272
|
+
let parsed;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(readFileSync(abs, 'utf-8'));
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return { notScene: `not valid JSON: ${String(error.message ?? error)}` };
|
|
277
|
+
}
|
|
278
|
+
const facts = sceneFacts(parsed);
|
|
279
|
+
return facts.ok
|
|
280
|
+
? {
|
|
281
|
+
scene: {
|
|
282
|
+
name: facts.name,
|
|
283
|
+
...(facts.dimension ? { dimension: facts.dimension } : {}),
|
|
284
|
+
nodes: facts.nodes,
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
: { notScene: facts.why };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Root-bounded, .json-only — the browser offers every JSON and the LOAD decides,
|
|
292
|
+
* but this must never become a way to read .env or somebody's key file.
|
|
293
|
+
*/
|
|
118
294
|
function resolveSceneFile(rel) {
|
|
119
|
-
if (typeof rel !== 'string' || !rel.endsWith('.
|
|
295
|
+
if (typeof rel !== 'string' || !rel.endsWith('.json')) return null;
|
|
120
296
|
const abs = normalize(resolve(ROOT, rel));
|
|
121
297
|
if (abs !== ROOT && !abs.startsWith(ROOT + sep)) return null;
|
|
122
298
|
return abs;
|
|
@@ -167,18 +343,6 @@ function originAllowed(req) {
|
|
|
167
343
|
}
|
|
168
344
|
}
|
|
169
345
|
|
|
170
|
-
function isSceneShaped(json) {
|
|
171
|
-
return (
|
|
172
|
-
json !== null &&
|
|
173
|
-
typeof json === 'object' &&
|
|
174
|
-
json.format === 1 &&
|
|
175
|
-
json.type === 'scene' &&
|
|
176
|
-
typeof json.name === 'string' &&
|
|
177
|
-
json.root !== null &&
|
|
178
|
-
typeof json.root === 'object'
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
346
|
function readBody(req, res, onJson) {
|
|
183
347
|
const chunks = [];
|
|
184
348
|
let received = 0;
|
|
@@ -217,6 +381,14 @@ const server = createServer((req, res) => {
|
|
|
217
381
|
);
|
|
218
382
|
}
|
|
219
383
|
|
|
384
|
+
if (url.pathname === '/api/library') {
|
|
385
|
+
if (req.method !== 'GET') {
|
|
386
|
+
return send(res, 405, JSON.stringify({ error: 'method not allowed' }));
|
|
387
|
+
}
|
|
388
|
+
proxyLibrary(req, res, url);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
220
392
|
if (url.pathname === '/api/scenes') {
|
|
221
393
|
if (MODE !== 'project') {
|
|
222
394
|
return send(res, 400, JSON.stringify({ error: 'scene listing is project-mode only' }));
|
|
@@ -229,18 +401,23 @@ const server = createServer((req, res) => {
|
|
|
229
401
|
return send(res, 403, JSON.stringify({ error: 'cross-origin write rejected' }));
|
|
230
402
|
}
|
|
231
403
|
readBody(req, res, (body) => {
|
|
232
|
-
|
|
404
|
+
// a path with no extension means the usual thing, so `levels/arena` works
|
|
405
|
+
const wanted =
|
|
406
|
+
typeof body?.path === 'string' && body.path && !body.path.endsWith('.json')
|
|
407
|
+
? `${body.path}.scene.json`
|
|
408
|
+
: body?.path;
|
|
409
|
+
const abs = resolveSceneFile(wanted);
|
|
233
410
|
if (!abs) {
|
|
234
411
|
return send(
|
|
235
412
|
res,
|
|
236
413
|
400,
|
|
237
|
-
JSON.stringify({ error: 'path must be a
|
|
414
|
+
JSON.stringify({ error: 'path must be a .json file inside the project' }),
|
|
238
415
|
);
|
|
239
416
|
}
|
|
240
417
|
if (existsSync(abs)) {
|
|
241
|
-
return send(res, 409, JSON.stringify({ error: `already exists: ${
|
|
418
|
+
return send(res, 409, JSON.stringify({ error: `already exists: ${wanted}` }));
|
|
242
419
|
}
|
|
243
|
-
const name = basename(abs).replace(/\.scene\.json$/, '') || 'Scene';
|
|
420
|
+
const name = basename(abs).replace(/\.scene\.json$|\.json$/, '') || 'Scene';
|
|
244
421
|
const scene = {
|
|
245
422
|
format: 1,
|
|
246
423
|
type: 'scene',
|
|
@@ -271,26 +448,39 @@ const server = createServer((req, res) => {
|
|
|
271
448
|
);
|
|
272
449
|
}
|
|
273
450
|
if (req.method === 'GET') {
|
|
451
|
+
let text;
|
|
452
|
+
let parsed;
|
|
274
453
|
try {
|
|
275
|
-
|
|
276
|
-
JSON.parse(text); // must at least be JSON before the page gets it
|
|
277
|
-
return send(res, 200, text);
|
|
454
|
+
text = readFileSync(target.input, 'utf-8');
|
|
455
|
+
parsed = JSON.parse(text); // must at least be JSON before the page gets it
|
|
278
456
|
} catch (error) {
|
|
279
457
|
return send(res, 422, JSON.stringify({ error: String(error.message ?? error) }));
|
|
280
458
|
}
|
|
459
|
+
// A JSON that is not a scene is refused WITH THE REASON and its contents
|
|
460
|
+
// never leave: "offer everything, explain the no" must not also mean
|
|
461
|
+
// "this server will read you any JSON in the project".
|
|
462
|
+
const facts = sceneFacts(parsed);
|
|
463
|
+
if (!facts.ok) {
|
|
464
|
+
return send(
|
|
465
|
+
res,
|
|
466
|
+
422,
|
|
467
|
+
JSON.stringify({ error: `not an incanto scene — ${facts.why}.`, notScene: true }),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return send(res, 200, text);
|
|
281
471
|
}
|
|
282
472
|
if (req.method === 'PUT') {
|
|
283
473
|
if (!originAllowed(req)) {
|
|
284
474
|
return send(res, 403, JSON.stringify({ error: 'cross-origin write rejected' }));
|
|
285
475
|
}
|
|
286
476
|
readBody(req, res, (json, raw) => {
|
|
287
|
-
|
|
477
|
+
const shape = sceneFacts(json);
|
|
478
|
+
if (!shape.ok) {
|
|
288
479
|
return send(
|
|
289
480
|
res,
|
|
290
481
|
422,
|
|
291
482
|
JSON.stringify({
|
|
292
|
-
error:
|
|
293
|
-
'not an incanto scene — required: format: 1, type: "scene", name: string, root: object. The file was NOT written.',
|
|
483
|
+
error: `not an incanto scene — ${shape.why}. The file was NOT written.`,
|
|
294
484
|
}),
|
|
295
485
|
);
|
|
296
486
|
}
|
package/dist/2d.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { s as JsonObject } from "./schema-CcoWb32N.js";
|
|
2
|
-
import { $ as Node, C as RendererStats, S as GameStats, b as Scheduler, l as PropSchema, n as BehaviorCtor, v as Engine, w as Scene$1 } from "./behavior-
|
|
3
|
-
import { t as
|
|
1
|
+
import { i as SceneJson, s as JsonObject } from "./schema-CcoWb32N.js";
|
|
2
|
+
import { $ as Node, C as RendererStats, S as GameStats, b as Scheduler, l as PropSchema, n as BehaviorCtor, v as Engine, w as Scene$1 } from "./behavior-BAc0erXF.js";
|
|
3
|
+
import { t as EditorSwitchOptions } from "./editor-switch-B0wB_DSr.js";
|
|
4
|
+
import { t as LoadSceneOptions } from "./loader-B242FF6N.js";
|
|
4
5
|
import { t as ParticleSim } from "./particle-sim-CwJ5rI_P.js";
|
|
5
6
|
import { Group, Mesh, Object3D, Scene, Texture } from "three";
|
|
6
7
|
import * as RapierNs from "@dimforge/rapier2d-compat";
|
|
@@ -145,6 +146,12 @@ type Rapier = typeof RapierNs;
|
|
|
145
146
|
interface Physics2DOptions {
|
|
146
147
|
/** px/s², y-down. Default: scene `physics.gravity`, else [0, 980]. */
|
|
147
148
|
gravity?: [number, number];
|
|
149
|
+
/**
|
|
150
|
+
* Build the colliders and keep them on the tree, but never advance the world
|
|
151
|
+
* (default: `true`, i.e. simulate). See {@link Physics3DOptions.simulate} —
|
|
152
|
+
* the scene editor uses it to draw the REAL colliders without running them.
|
|
153
|
+
*/
|
|
154
|
+
simulate?: boolean;
|
|
148
155
|
}
|
|
149
156
|
/**
|
|
150
157
|
* Enable 2D physics for an engine. Dynamically imports Rapier (compat build,
|
|
@@ -157,9 +164,11 @@ declare function enablePhysics2D(engine: Engine, opts?: Physics2DOptions): Promi
|
|
|
157
164
|
*/
|
|
158
165
|
declare class Physics2D {
|
|
159
166
|
private readonly R;
|
|
160
|
-
|
|
167
|
+
readonly engine: Engine;
|
|
161
168
|
/** Render collider outlines in the GAME view (renderers pick this up). */
|
|
162
169
|
debugDraw: boolean;
|
|
170
|
+
/** Narrow those outlines to one node's subtree (see DebugLineSource). */
|
|
171
|
+
debugScope: object | null;
|
|
163
172
|
readonly dimension = "2d";
|
|
164
173
|
private readonly unregisterDebug;
|
|
165
174
|
private readonly warnedNoCollider;
|
|
@@ -174,11 +183,20 @@ declare class Physics2D {
|
|
|
174
183
|
private lastScene;
|
|
175
184
|
constructor(R: Rapier, engine: Engine, opts?: Physics2DOptions);
|
|
176
185
|
/** @internal Driven by engine.fixedUpdated. */
|
|
186
|
+
/** Whether the solver runs at all. False = collider outlines only. */
|
|
187
|
+
private readonly simulate;
|
|
177
188
|
step(dt: number): void;
|
|
178
189
|
/** @internal Called by CharacterBody2D.moveAndSlide (during tree fixedUpdate). */
|
|
179
190
|
moveAndSlide(node: CharacterBody2D): void;
|
|
180
191
|
/** Rapier debug segments, scaled back to pixels. Null while debugDraw is off. */
|
|
181
192
|
debugLines(): Float32Array | null;
|
|
193
|
+
/**
|
|
194
|
+
* Only the selected node's colliders (meters — the caller scales to pixels).
|
|
195
|
+
* Rapier draws the whole world or nothing, so a chosen few are outlined from
|
|
196
|
+
* the same shape data the solver holds. 2D colliders are rect/circle/capsule,
|
|
197
|
+
* which is the whole switch.
|
|
198
|
+
*/
|
|
199
|
+
private scopedLines;
|
|
182
200
|
/** Create newly-arrived joints; tear down departed ones. */
|
|
183
201
|
private syncJoints;
|
|
184
202
|
/**
|
|
@@ -200,6 +218,13 @@ declare class Physics2D {
|
|
|
200
218
|
private readonly jointSet;
|
|
201
219
|
private readonly joints;
|
|
202
220
|
private readonly bodySet;
|
|
221
|
+
/**
|
|
222
|
+
* Move every body to its node's current transform — outline mode only.
|
|
223
|
+
*
|
|
224
|
+
* Computed exactly as `ensureEntry` computes a body's first pose, so an
|
|
225
|
+
* outline drawn in the editor is the outline the running game draws.
|
|
226
|
+
*/
|
|
227
|
+
private poseFromTree;
|
|
203
228
|
private syncBodies;
|
|
204
229
|
private ensureEntry;
|
|
205
230
|
}
|
|
@@ -249,6 +274,17 @@ interface CreateGame2DOptions {
|
|
|
249
274
|
* to development, e.g. `debug: import.meta.env.VITE_INCANTO_DEBUG === '1'`.
|
|
250
275
|
*/
|
|
251
276
|
debug?: boolean;
|
|
277
|
+
/**
|
|
278
|
+
* LIVE EDITING: the ☰ debug menu gains "edit this scene", which turns this
|
|
279
|
+
* page into the scene editor — same window, no reload — and the editor's play
|
|
280
|
+
* button turns it back into the game.
|
|
281
|
+
*
|
|
282
|
+
* DEFAULTS TO `debug`, costs nothing until clicked (the editor is a lazy
|
|
283
|
+
* chunk), and is never reachable without it — the ☰ menu is its only
|
|
284
|
+
* entrance. `false` opts out; `{ open }` loads the editor yourself (apps that
|
|
285
|
+
* alias incanto to source); `{ save }` makes its save button write your file.
|
|
286
|
+
*/
|
|
287
|
+
editor?: boolean | EditorSwitchOptions;
|
|
252
288
|
/** @internal Test seam — replaces `document` for the debug overlay. */
|
|
253
289
|
_debugDoc?: {
|
|
254
290
|
createElement(tag: string): HTMLElement;
|
|
@@ -272,10 +308,22 @@ interface GameRenderer {
|
|
|
272
308
|
interface Game2D {
|
|
273
309
|
engine: Engine;
|
|
274
310
|
scene: Scene$1;
|
|
311
|
+
/**
|
|
312
|
+
* The AUTHORED scene this game booted from — the JSON, not the live tree.
|
|
313
|
+
* A running tree has moved; the editor gets what was written down.
|
|
314
|
+
*/
|
|
315
|
+
sourceJson: SceneJson;
|
|
275
316
|
renderer: GameRenderer;
|
|
276
317
|
physics: Physics2D | null;
|
|
277
318
|
/** Engine + renderer perf counters in one read (fps/nodes/triangles/…). */
|
|
278
319
|
stats(): GameStats;
|
|
320
|
+
/**
|
|
321
|
+
* Turn this page into the scene editor — what the ☰ debug menu's "edit this
|
|
322
|
+
* scene" calls. The game is disposed on the way out and rebooted when the
|
|
323
|
+
* editor hands it back, so THIS handle is dead afterwards. `null` when the
|
|
324
|
+
* game was booted with `editor: false`.
|
|
325
|
+
*/
|
|
326
|
+
openEditor: (() => Promise<void>) | null;
|
|
279
327
|
/** Tear the whole game down (renderer, loop, scene, listeners). */
|
|
280
328
|
dispose(): void;
|
|
281
329
|
}
|
|
@@ -724,8 +772,8 @@ declare class Renderer2D {
|
|
|
724
772
|
/** The world view used by the LAST render (center/zoom/canvas size). */
|
|
725
773
|
view(): View2D;
|
|
726
774
|
/**
|
|
727
|
-
* GPU counters for the LAST rendered frame
|
|
728
|
-
*
|
|
775
|
+
* GPU counters for the LAST rendered frame — world + UI passes combined (see
|
|
776
|
+
* the `info.reset()` in the render pass), plus GPU memory.
|
|
729
777
|
*/
|
|
730
778
|
stats(): RendererStats;
|
|
731
779
|
worldFromScreen(sx: number, sy: number): {
|
package/dist/2d.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
2
|
-
import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-
|
|
3
|
-
import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-
|
|
4
|
-
import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-
|
|
2
|
+
import { a as AssetStore2D, i as syncTree2D, r as Renderer2D, t as createGame2D } from "./create-game-5z_QVtLx.js";
|
|
3
|
+
import { _ as RigidBody2D, a as parseCells, c as ColorRect2D, d as AnimatedSprite2D, f as Sprite2D, g as PhysicsBody2D, h as CharacterBody2D, i as mergeSolidRects, l as CharacterController2D, m as Area2D, n as UILayer, o as Particles2D, p as Joint2D, r as TileMap2D, s as Label, t as registerNodes2D, u as Camera2D, v as StaticBody2D, y as Node2D } from "./register-DWcWq4QG.js";
|
|
4
|
+
import { n as enablePhysics2D, t as Physics2D } from "./physics-2d-3kOQCtgd.js";
|
|
5
5
|
//#region src/2d/library-sprite.ts
|
|
6
6
|
/**
|
|
7
7
|
* Turn a library sprite-animation JSON into a scene-ready spritesheet asset
|