incanto 0.29.0 → 0.31.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-editor.mjs +101 -28
- package/dist/2d.d.ts +28 -6
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +81 -7
- package/dist/3d.js +4 -4
- package/dist/{audit-C6rMyict.js → audit-C4kmDK0o.js} +16 -0
- package/dist/{behavior-BAc0erXF.d.ts → behavior-D_jMpFh8.d.ts} +117 -55
- package/dist/{create-game-CNKXGfpr.js → create-game-DqOjMBUS.js} +61 -48
- package/dist/{create-game-CbuLWorm.js → create-game-z5XaB1p5.js} +27 -15
- package/dist/debug-draw-BM3DsvtT.js +18 -0
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +8 -3
- package/dist/{duplicate-BgtFrFo4.js → duplicate-KRPtUtzl.js} +1 -1
- package/dist/{editor-switch-B0wB_DSr.d.ts → editor-switch-BJb-CWfA.d.ts} +12 -1
- package/dist/editor.js +1346 -1231
- package/dist/{register-C44aSduO.js → environment-presets--DigHNg4.js} +42 -11
- package/dist/{gameplay-L05WgWd1.js → gameplay-BftxM_It.js} +14 -5
- package/dist/gameplay.d.ts +1 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.js +6 -6
- package/dist/{loader-B242FF6N.d.ts → loader-BYBrqTxP.d.ts} +1 -1
- package/dist/{loader-BZqOKfI2.js → loader-BlaRQGaA.js} +482 -6
- package/dist/net.d.ts +1 -1
- package/dist/net.js +3 -3
- package/dist/{pathfinding-BwD974Ss.d.ts → pathfinding-BwhqPD3i.d.ts} +1 -1
- package/dist/{physics-2d-CCVTrKOd.js → physics-2d-D9wquBvK.js} +30 -4
- package/dist/{physics-3d-BZZLtwJu.js → physics-3d-CnPygVGo.js} +41 -5
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-MelqEdza.js → register-CUY284Is.js} +3 -3
- package/dist/{register-BTg0EM7s.js → register-Dzkd6-os.js} +56 -380
- package/dist/{register-DWcWq4QG.js → register-tkR_8tWg.js} +4 -4
- package/dist/{registry-BVJ2HbCn.js → registry-CJdGpT2V.js} +6 -2
- package/dist/{editor-switch-CAKlJMIY.js → teardown-ByzfDPyu.js} +83 -4
- package/dist/test.d.ts +2 -7
- package/dist/test.js +43 -14
- package/dist/vite.d.ts +87 -1
- package/dist/vite.js +262 -3
- package/editor/assets/{agent8-DEVkEa3d.js → agent8-o27_Y1xN.js} +1 -1
- package/editor/assets/{debug-zGAtpDF0.js → debug-DfcWX3uW.js} +3 -2
- package/editor/assets/index-C9fb5QcT.js +10696 -0
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/skills/incanto-editor.md +61 -8
- package/skills/incanto-physics-and-input.md +15 -0
- package/skills/incanto-verifying-your-game.md +68 -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/package.json +1 -1
- package/templates-app/tps-3d/vite.config.ts +7 -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-Bx4UtWYY.js +0 -10586
|
@@ -69,6 +69,35 @@ function devServerLibrary(token) {
|
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
+
* Does the page's own dev server serve the project's scenes?
|
|
73
|
+
*
|
|
74
|
+
* `incantoScenes()` from `incanto/vite` (the plugin that already validates
|
|
75
|
+
* scenes on save; `incanto-new` puts it in every scaffolded project) answers
|
|
76
|
+
* `GET /api/scenes`. When it does, the
|
|
77
|
+
* editor a game switches into is a PROJECT editor: it can list, open, create
|
|
78
|
+
* and save every scene in the project, and play whichever one you loaded.
|
|
79
|
+
* When it does not (a built game, someone else's server), the editor still
|
|
80
|
+
* edits the scene the game is running, exactly as before.
|
|
81
|
+
*/
|
|
82
|
+
async function projectScenes() {
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch("/api/scenes");
|
|
85
|
+
if (!res.ok) return null;
|
|
86
|
+
const body = await res.json();
|
|
87
|
+
return Array.isArray(body) ? body : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** What to say when the dev server has no idea what scenes this project has. */
|
|
93
|
+
const NO_SCENE_ROUTES = "this dev server does not serve the project's scenes yet. Add incantoScenes() to vite.config.ts and restart:\n\n import { incantoScenes } from 'incanto/vite';\n export default defineConfig({ plugins: [incantoScenes()] });\n\nIt also validates every scene you save, before the page reloads into a broken one.";
|
|
94
|
+
async function readProjectScene(file) {
|
|
95
|
+
const res = await fetch(`/api/scene?file=${encodeURIComponent(file)}`);
|
|
96
|
+
if (res.ok) return await res.json();
|
|
97
|
+
const body = await res.json().catch(() => null);
|
|
98
|
+
throw new Error(body?.error ?? `could not open ${file}: HTTP ${res.status}`);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
72
101
|
* The default opener: pull `incanto/editor` in on demand and mount it.
|
|
73
102
|
*
|
|
74
103
|
* It is a dynamic import so the editor — an IDE's worth of UI — never enters a
|
|
@@ -83,18 +112,44 @@ async function openBundledEditor(opts) {
|
|
|
83
112
|
throw new Error(`createGame3D editor: could not load 'incanto/editor'. If this app aliases incanto to the engine SOURCE (the examples and playground do), add an alias for 'incanto/editor' → packages/editor/src/main.ts — or pass editor: { open } and load it yourself. (${error instanceof Error ? error.message : String(error)})`);
|
|
84
113
|
}
|
|
85
114
|
let handle = null;
|
|
115
|
+
const project = await projectScenes();
|
|
116
|
+
const format = mod.formatSceneJson ?? ((json) => JSON.stringify(json, null, 2));
|
|
117
|
+
const writeProjectScene = async (file, json) => {
|
|
118
|
+
const res = await fetch(`/api/scene?file=${encodeURIComponent(file)}`, {
|
|
119
|
+
method: "PUT",
|
|
120
|
+
body: format(json)
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) throw new Error(`could not save ${file}: HTTP ${res.status} ${await res.text()}`);
|
|
123
|
+
};
|
|
86
124
|
handle = await mod.mountEditor({
|
|
87
125
|
...opts.camera ? { camera: opts.camera } : {},
|
|
88
126
|
api: {
|
|
89
127
|
meta: async () => ({
|
|
90
128
|
version: "live",
|
|
91
|
-
mode: "
|
|
129
|
+
mode: "project",
|
|
92
130
|
root: "",
|
|
93
131
|
input: null,
|
|
94
132
|
output: null
|
|
95
133
|
}),
|
|
96
|
-
loadScene: async () => opts.scene,
|
|
97
|
-
...opts.save ? { saveScene: async (json) =>
|
|
134
|
+
loadScene: async (file) => file ? await readProjectScene(file) : opts.scene,
|
|
135
|
+
...opts.save || project ? { saveScene: async (json, file) => {
|
|
136
|
+
if (file) return writeProjectScene(file, json);
|
|
137
|
+
await opts.save?.(json);
|
|
138
|
+
} } : {},
|
|
139
|
+
scenes: async () => {
|
|
140
|
+
const found = await projectScenes();
|
|
141
|
+
if (!found) throw new Error(NO_SCENE_ROUTES);
|
|
142
|
+
return found;
|
|
143
|
+
},
|
|
144
|
+
createScene: async (path) => {
|
|
145
|
+
const res = await fetch("/api/scenes", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: JSON.stringify({ path })
|
|
148
|
+
});
|
|
149
|
+
if (res.status === 404) throw new Error(NO_SCENE_ROUTES);
|
|
150
|
+
if (!res.ok) throw new Error(`could not create ${path}: ${await res.text()}`);
|
|
151
|
+
return await res.json();
|
|
152
|
+
},
|
|
98
153
|
...opts.library ? { library: opts.library } : {}
|
|
99
154
|
},
|
|
100
155
|
onPlay: (choice, working) => {
|
|
@@ -158,4 +213,28 @@ function poseFromRenderer(renderer, fallbackDistance = 8) {
|
|
|
158
213
|
};
|
|
159
214
|
}
|
|
160
215
|
//#endregion
|
|
161
|
-
|
|
216
|
+
//#region src/core/teardown.ts
|
|
217
|
+
function teardown(steps, onError = defaultReport) {
|
|
218
|
+
const failures = [];
|
|
219
|
+
for (const step of steps) {
|
|
220
|
+
if (!step) continue;
|
|
221
|
+
const [what, run] = step;
|
|
222
|
+
try {
|
|
223
|
+
run();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
failures.push({
|
|
226
|
+
what,
|
|
227
|
+
error
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
onError(what, error);
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { failures };
|
|
235
|
+
}
|
|
236
|
+
function defaultReport(what, error) {
|
|
237
|
+
console.error(`[incanto] teardown: ${what} failed (continuing)`, error);
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
export { poseFromRenderer as a, openBundledEditor as i, crossFade as n, devServerLibrary as r, teardown as t };
|
package/dist/test.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { $ as LogEntry, X as Node, n as BehaviorCtor, v as Engine, w as Scene } from "./behavior-D_jMpFh8.js";
|
|
1
2
|
import { c as JsonValue, i as SceneJson } from "./schema-CcoWb32N.js";
|
|
2
|
-
import {
|
|
3
|
-
import { t as LoadSceneOptions } from "./loader-B242FF6N.js";
|
|
3
|
+
import { t as LoadSceneOptions } from "./loader-BYBrqTxP.js";
|
|
4
4
|
import { i as auditScene, t as IncantoError } from "./errors-1dXlIwoR.js";
|
|
5
5
|
|
|
6
6
|
//#region src/test/index.d.ts
|
|
@@ -74,11 +74,6 @@ interface ValidationResult {
|
|
|
74
74
|
/** The hard load error, when ok is false — `details` is machine-readable. */
|
|
75
75
|
error?: IncantoError;
|
|
76
76
|
}
|
|
77
|
-
/**
|
|
78
|
-
* Run every hard load-time check headlessly. Unregistered behaviors are
|
|
79
|
-
* stubbed by default (structure-only validation, no TypeScript needed) —
|
|
80
|
-
* pass the real classes via `behaviors` to validate script props too.
|
|
81
|
-
*/
|
|
82
77
|
declare function validateScene(json: unknown, opts?: ValidateSceneOptions): ValidationResult;
|
|
83
78
|
interface RunContext {
|
|
84
79
|
engine: Engine;
|
package/dist/test.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { u as Engine } from "./register-
|
|
1
|
+
import { n as loadScene, y as registerBehavior } from "./loader-BlaRQGaA.js";
|
|
2
|
+
import { u as Engine } from "./register-Dzkd6-os.js";
|
|
3
3
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
4
|
-
import { t as auditScene } from "./audit-
|
|
4
|
+
import { t as auditScene } from "./audit-C4kmDK0o.js";
|
|
5
5
|
import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
|
|
6
|
-
import { i as getNodeSchema, s as mergeStaticProps } from "./registry-
|
|
7
|
-
import { n as registerGameplayBehaviors } from "./gameplay-
|
|
8
|
-
import { t as registerNodes2D } from "./register-
|
|
9
|
-
import {
|
|
10
|
-
import { t as registerNodesNet } from "./register-
|
|
6
|
+
import { i as getNodeSchema, s as mergeStaticProps } from "./registry-CJdGpT2V.js";
|
|
7
|
+
import { n as registerGameplayBehaviors } from "./gameplay-BftxM_It.js";
|
|
8
|
+
import { t as registerNodes2D } from "./register-tkR_8tWg.js";
|
|
9
|
+
import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets--DigHNg4.js";
|
|
10
|
+
import { t as registerNodesNet } from "./register-CUY284Is.js";
|
|
11
11
|
//#region src/test/index.ts
|
|
12
12
|
/**
|
|
13
13
|
* incanto/test — the browserless verification harness.
|
|
@@ -184,10 +184,28 @@ function defaultsFor(typeName) {
|
|
|
184
184
|
* stubbed by default (structure-only validation, no TypeScript needed) —
|
|
185
185
|
* pass the real classes via `behaviors` to validate script props too.
|
|
186
186
|
*/
|
|
187
|
+
/**
|
|
188
|
+
* `environment.preset` is resolved deep inside Renderer3D, which no headless
|
|
189
|
+
* tool reaches — so a typo there passed `incanto-check` and hard-failed at boot.
|
|
190
|
+
* It cannot live in the core loader (core must never import 3d), but it belongs
|
|
191
|
+
* exactly here: the test entry already registers the 3D nodes.
|
|
192
|
+
*/
|
|
193
|
+
function checkEnvironment(json) {
|
|
194
|
+
const env = json?.environment;
|
|
195
|
+
if (!env || typeof env !== "object") return;
|
|
196
|
+
if (env.preset === void 0) return;
|
|
197
|
+
if (typeof env.preset !== "string") throw new IncantoError("BAD_FORMAT", `Scene "environment.preset" must be a string, got ${JSON.stringify(env.preset)}.`);
|
|
198
|
+
try {
|
|
199
|
+
resolveEnvironmentHdri(env);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new IncantoError("BAD_FORMAT", error instanceof Error ? error.message : String(error));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
187
204
|
function validateScene(json, opts = {}) {
|
|
188
205
|
registerAllNodes();
|
|
189
206
|
for (const [name, ctor] of Object.entries(opts.behaviors ?? {})) registerBehavior(name, ctor, { replace: true });
|
|
190
207
|
try {
|
|
208
|
+
checkEnvironment(json);
|
|
191
209
|
loadScene(json, {
|
|
192
210
|
resolveScene: opts.resolveScene,
|
|
193
211
|
stubMissingBehaviors: !opts.strictBehaviors
|
|
@@ -218,10 +236,10 @@ async function runScript(json, opts) {
|
|
|
218
236
|
engine.setScene(scene);
|
|
219
237
|
const physics = opts.physics ?? "auto";
|
|
220
238
|
if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
|
|
221
|
-
const { enablePhysics2D } = await import("./physics-2d-
|
|
239
|
+
const { enablePhysics2D } = await import("./physics-2d-D9wquBvK.js").then((n) => n.r);
|
|
222
240
|
await enablePhysics2D(engine);
|
|
223
241
|
} else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
|
|
224
|
-
const { enablePhysics3D } = await import("./physics-3d-
|
|
242
|
+
const { enablePhysics3D } = await import("./physics-3d-CnPygVGo.js").then((n) => n.r);
|
|
225
243
|
await enablePhysics3D(engine);
|
|
226
244
|
}
|
|
227
245
|
const failures = [];
|
|
@@ -284,6 +302,12 @@ async function runScript(json, opts) {
|
|
|
284
302
|
}
|
|
285
303
|
const finalCapture = captureScene(scene);
|
|
286
304
|
const logs = [...engine.log.entries()];
|
|
305
|
+
const swallowed = engine.stats().errors;
|
|
306
|
+
if (swallowed > 0) failures.push({
|
|
307
|
+
atMs: opts.durationMs,
|
|
308
|
+
label: "engine",
|
|
309
|
+
message: `${swallowed} error(s) were swallowed to keep the loop alive — the logs above name the node, the behavior and the phase`
|
|
310
|
+
});
|
|
287
311
|
engine.dispose();
|
|
288
312
|
const ok = failures.length === 0;
|
|
289
313
|
return {
|
|
@@ -293,9 +317,14 @@ async function runScript(json, opts) {
|
|
|
293
317
|
logs,
|
|
294
318
|
finalCapture,
|
|
295
319
|
describe: () => {
|
|
320
|
+
const engineWarnings = logs.filter((l) => l.level === "warn" || l.level === "error");
|
|
321
|
+
const head = `run ${ok ? "OK" : "FAILED"} — ${opts.durationMs}ms simulated, ${failures.length} failure(s), ${logs.length} log(s)`;
|
|
322
|
+
const fails = failures.map((f) => ` ✗ at ${f.atMs}ms${f.label ? ` [${f.label}]` : ""}: ${f.message}`);
|
|
323
|
+
const noisy = engineWarnings.map((l) => ` ! ${l.level}: ${l.parts.map((p) => typeof p === "string" ? p : String(p)).join(" ")}`);
|
|
296
324
|
return [
|
|
297
|
-
|
|
298
|
-
...
|
|
325
|
+
head,
|
|
326
|
+
...fails,
|
|
327
|
+
...noisy,
|
|
299
328
|
describeCapture(finalCapture)
|
|
300
329
|
].join("\n");
|
|
301
330
|
}
|
|
@@ -323,10 +352,10 @@ async function createPlaySession(json, opts = {}) {
|
|
|
323
352
|
engine.setScene(scene);
|
|
324
353
|
const physics = opts.physics ?? "auto";
|
|
325
354
|
if (physics === "2d" || physics === "auto" && scene.dimension === "2d") {
|
|
326
|
-
const { enablePhysics2D } = await import("./physics-2d-
|
|
355
|
+
const { enablePhysics2D } = await import("./physics-2d-D9wquBvK.js").then((n) => n.r);
|
|
327
356
|
await enablePhysics2D(engine);
|
|
328
357
|
} else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
|
|
329
|
-
const { enablePhysics3D } = await import("./physics-3d-
|
|
358
|
+
const { enablePhysics3D } = await import("./physics-3d-CnPygVGo.js").then((n) => n.r);
|
|
330
359
|
await enablePhysics3D(engine);
|
|
331
360
|
}
|
|
332
361
|
const stepMs = 1e3 / (opts.fixedHz ?? 60);
|
package/dist/vite.d.ts
CHANGED
|
@@ -17,11 +17,97 @@ interface HotUpdateContext {
|
|
|
17
17
|
interface IncantoScenesOptions {
|
|
18
18
|
/** Hard-fail on unregistered behaviors too (default: structure-only). */
|
|
19
19
|
strictBehaviors?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Where scenes are found and written. Default: vite's `root` (your project).
|
|
22
|
+
* Nothing outside it is readable or writable, whatever a request asks for.
|
|
23
|
+
*/
|
|
24
|
+
root?: string;
|
|
20
25
|
}
|
|
21
26
|
declare function incantoScenes(opts?: IncantoScenesOptions): {
|
|
22
27
|
name: string;
|
|
23
28
|
handleHotUpdate(ctx: HotUpdateContext): Promise<undefined | never[]>;
|
|
29
|
+
configureServer(server: SceneDevServer): void;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* `GET /api/scenes` → every `*.scene.json` under the project root
|
|
33
|
+
* `POST /api/scenes` `{path}` → create a minimal scene there
|
|
34
|
+
* `GET /api/scene?file=<rel>` → that scene's JSON
|
|
35
|
+
* `PUT /api/scene?file=<rel>` → write it back (bytes verbatim)
|
|
36
|
+
*
|
|
37
|
+
* These are the routes `incanto-editor` serves, on YOUR dev server, so the
|
|
38
|
+
* editor a game switches into is a project editor rather than a single-file
|
|
39
|
+
* one: it can list, open, create and save any scene in the project, and play
|
|
40
|
+
* whichever one you loaded.
|
|
41
|
+
*
|
|
42
|
+
* Everything is bounded to the root and to `*.scene.json`. A dev server is
|
|
43
|
+
* still a server: `?file=../../.ssh/id_rsa` has to be a 400, not a read.
|
|
44
|
+
*/
|
|
45
|
+
interface SceneDevServer {
|
|
46
|
+
config?: {
|
|
47
|
+
root?: string;
|
|
48
|
+
};
|
|
49
|
+
middlewares: {
|
|
50
|
+
use(path: string, handler: (req: ApiReq, res: LibraryRes) => void): void;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
interface ApiReq extends LibraryReq {
|
|
54
|
+
on(event: "data" | "end" | "error", cb: (chunk?: unknown) => void): void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* One JSON file in the project, and whether it is a scene.
|
|
58
|
+
*
|
|
59
|
+
* A file browser that only shows `*.scene.json` cannot show you the scene you
|
|
60
|
+
* saved as `level.json` — and, worse, gives no reason for its absence. So the
|
|
61
|
+
* list is every `.json` under the project (a scene is JSON by definition, so
|
|
62
|
+
* nothing else can be one), and `scene` says which of them actually are.
|
|
63
|
+
*/
|
|
64
|
+
interface ProjectFileEntry {
|
|
65
|
+
rel: string;
|
|
66
|
+
abs: string;
|
|
67
|
+
size: number;
|
|
68
|
+
modified: number;
|
|
69
|
+
/** Present only when the file IS a scene — read from the file, not the name. */
|
|
70
|
+
scene?: {
|
|
71
|
+
name: string;
|
|
72
|
+
dimension?: "2d" | "3d";
|
|
73
|
+
nodes: number;
|
|
74
|
+
};
|
|
75
|
+
/** Why it is not one, in a form a person can act on. */
|
|
76
|
+
notScene?: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Is this JSON an incanto scene? The file itself answers.
|
|
80
|
+
*
|
|
81
|
+
* A scene declares what it is — `{"format": 1, "type": "scene", "name", "root"}`
|
|
82
|
+
* — and those are exactly the four keys the loader demands before it will build
|
|
83
|
+
* anything (`validateHeader`). So "is this a scene" is not a guess from the file
|
|
84
|
+
* name; it is a fact readable from the first object in the file. That is what
|
|
85
|
+
* lets the browser mark a row ✓ instead of hoping.
|
|
86
|
+
*/
|
|
87
|
+
declare function sceneFacts(json: unknown): {
|
|
88
|
+
ok: true;
|
|
89
|
+
name: string;
|
|
90
|
+
dimension?: "2d" | "3d";
|
|
91
|
+
nodes: number;
|
|
92
|
+
} | {
|
|
93
|
+
ok: false;
|
|
94
|
+
why: string;
|
|
24
95
|
};
|
|
96
|
+
/** @internal Exported for tests. Root-bounded walk, deepest 6 levels. */
|
|
97
|
+
declare function discoverScenes(root: string): ProjectFileEntry[];
|
|
98
|
+
/**
|
|
99
|
+
* @internal The one place a request's path becomes a file path.
|
|
100
|
+
*
|
|
101
|
+
* `.json` under the project, nothing else — the file browser offers every JSON
|
|
102
|
+
* and the loader decides, but a dev server still must not be a way to read
|
|
103
|
+
* `.env` or somebody's key file. Content is gated separately: a JSON that is
|
|
104
|
+
* not a scene is never sent to the page, only the reason it is not one.
|
|
105
|
+
*/
|
|
106
|
+
declare function resolveSceneFile(root: string, rel: unknown): string | null;
|
|
107
|
+
/** @internal Exported for tests — `GET`/`POST /api/scenes`. */
|
|
108
|
+
declare function serveSceneList(req: ApiReq, res: LibraryRes, root: string): Promise<void>;
|
|
109
|
+
/** @internal Exported for tests — `GET`/`PUT /api/scene?file=`. */
|
|
110
|
+
declare function serveSceneFile(req: ApiReq, res: LibraryRes, root: string): Promise<void>;
|
|
25
111
|
interface DevServer {
|
|
26
112
|
middlewares: {
|
|
27
113
|
use(path: string, handler: (req: LibraryReq, res: LibraryRes) => void): void;
|
|
@@ -75,4 +161,4 @@ declare function incantoLibrary(opts?: IncantoLibraryOptions): {
|
|
|
75
161
|
/** @internal Exported for tests — the whole request/response behaviour. */
|
|
76
162
|
declare function serveLibrary(req: LibraryReq, res: LibraryRes, opts?: IncantoLibraryOptions, doFetch?: typeof fetch): Promise<void>;
|
|
77
163
|
//#endregion
|
|
78
|
-
export { IncantoLibraryOptions, IncantoScenesOptions, incantoLibrary, incantoScenes, serveLibrary };
|
|
164
|
+
export { IncantoLibraryOptions, IncantoScenesOptions, ProjectFileEntry, discoverScenes, incantoLibrary, incantoScenes, resolveSceneFile, sceneFacts, serveLibrary, serveSceneFile, serveSceneList };
|
package/dist/vite.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { validateScene } from "./test.js";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
|
|
4
4
|
//#region src/vite/index.ts
|
|
5
5
|
/**
|
|
6
6
|
* incanto/vite — dev-server integration: validate every `*.scene.json` edit
|
|
@@ -24,6 +24,20 @@ import { dirname, resolve } from "node:path";
|
|
|
24
24
|
function incantoScenes(opts = {}) {
|
|
25
25
|
return {
|
|
26
26
|
name: "incanto-scenes",
|
|
27
|
+
/**
|
|
28
|
+
* The project's scenes, over HTTP, so the editor opened from a running
|
|
29
|
+
* game can browse and load them the same way the standalone
|
|
30
|
+
* `incanto-editor` does. Dev-only by construction (`configureServer`).
|
|
31
|
+
*/
|
|
32
|
+
configureServer(server) {
|
|
33
|
+
const root = opts.root ?? server.config?.root ?? process.cwd();
|
|
34
|
+
server.middlewares.use("/api/scenes", (req, res) => {
|
|
35
|
+
serveSceneList(req, res, root);
|
|
36
|
+
});
|
|
37
|
+
server.middlewares.use("/api/scene", (req, res) => {
|
|
38
|
+
serveSceneFile(req, res, root);
|
|
39
|
+
});
|
|
40
|
+
},
|
|
27
41
|
async handleHotUpdate(ctx) {
|
|
28
42
|
if (!ctx.file.endsWith(".scene.json")) return void 0;
|
|
29
43
|
let json;
|
|
@@ -58,6 +72,251 @@ function report(ctx, message) {
|
|
|
58
72
|
}
|
|
59
73
|
});
|
|
60
74
|
}
|
|
75
|
+
const SKIP_DIRS = new Set([
|
|
76
|
+
"node_modules",
|
|
77
|
+
"dist",
|
|
78
|
+
"build",
|
|
79
|
+
"out",
|
|
80
|
+
".git",
|
|
81
|
+
"coverage",
|
|
82
|
+
".vite",
|
|
83
|
+
".next"
|
|
84
|
+
]);
|
|
85
|
+
/**
|
|
86
|
+
* Is this JSON an incanto scene? The file itself answers.
|
|
87
|
+
*
|
|
88
|
+
* A scene declares what it is — `{"format": 1, "type": "scene", "name", "root"}`
|
|
89
|
+
* — and those are exactly the four keys the loader demands before it will build
|
|
90
|
+
* anything (`validateHeader`). So "is this a scene" is not a guess from the file
|
|
91
|
+
* name; it is a fact readable from the first object in the file. That is what
|
|
92
|
+
* lets the browser mark a row ✓ instead of hoping.
|
|
93
|
+
*/
|
|
94
|
+
function sceneFacts(json) {
|
|
95
|
+
if (json === null || typeof json !== "object" || Array.isArray(json)) return {
|
|
96
|
+
ok: false,
|
|
97
|
+
why: "the file is JSON, but not a JSON object"
|
|
98
|
+
};
|
|
99
|
+
const j = json;
|
|
100
|
+
if (j.type !== "scene") return {
|
|
101
|
+
ok: false,
|
|
102
|
+
why: `"type" must be "scene"${j.type === void 0 ? ", and this file has no \"type\"" : `, not ${JSON.stringify(j.type)}`}`
|
|
103
|
+
};
|
|
104
|
+
if (j.format !== 1) return {
|
|
105
|
+
ok: false,
|
|
106
|
+
why: `"format" must be 1, not ${JSON.stringify(j.format)}`
|
|
107
|
+
};
|
|
108
|
+
if (typeof j.root !== "object" || j.root === null) return {
|
|
109
|
+
ok: false,
|
|
110
|
+
why: "\"root\" must be a node object"
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
name: typeof j.name === "string" ? j.name : "",
|
|
115
|
+
...j.dimension === "2d" || j.dimension === "3d" ? { dimension: j.dimension } : {},
|
|
116
|
+
nodes: countNodes(j.root)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function countNodes(node) {
|
|
120
|
+
const children = node.children;
|
|
121
|
+
if (!Array.isArray(children)) return 1;
|
|
122
|
+
let n = 1;
|
|
123
|
+
for (const child of children) n += countNodes(child);
|
|
124
|
+
return n;
|
|
125
|
+
}
|
|
126
|
+
/** Reading a 300 MB "json" to label a row is not worth it — say so instead. */
|
|
127
|
+
const SNIFF_LIMIT = 16e6;
|
|
128
|
+
function inspect(abs, size) {
|
|
129
|
+
if (size > SNIFF_LIMIT) return { notScene: "too large to check without opening it" };
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(readFileSync(abs, "utf-8"));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return { notScene: `not valid JSON: ${message(error)}` };
|
|
135
|
+
}
|
|
136
|
+
const facts = sceneFacts(parsed);
|
|
137
|
+
return facts.ok ? { scene: {
|
|
138
|
+
name: facts.name,
|
|
139
|
+
...facts.dimension ? { dimension: facts.dimension } : {},
|
|
140
|
+
nodes: facts.nodes
|
|
141
|
+
} } : { notScene: facts.why };
|
|
142
|
+
}
|
|
143
|
+
/** @internal Exported for tests. Root-bounded walk, deepest 6 levels. */
|
|
144
|
+
function discoverScenes(root) {
|
|
145
|
+
const found = [];
|
|
146
|
+
const walk = (dir, depth) => {
|
|
147
|
+
if (depth > 6) return;
|
|
148
|
+
let entries;
|
|
149
|
+
try {
|
|
150
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const entry of entries) if (entry.isDirectory()) {
|
|
155
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) walk(join(dir, entry.name), depth + 1);
|
|
156
|
+
} else if (entry.name.endsWith(".json")) {
|
|
157
|
+
const abs = join(dir, entry.name);
|
|
158
|
+
let size = 0;
|
|
159
|
+
let modified = 0;
|
|
160
|
+
try {
|
|
161
|
+
const stat = statSync(abs);
|
|
162
|
+
size = stat.size;
|
|
163
|
+
modified = stat.mtimeMs;
|
|
164
|
+
} catch {}
|
|
165
|
+
found.push({
|
|
166
|
+
rel: relative(root, abs).split(sep).join("/"),
|
|
167
|
+
abs,
|
|
168
|
+
size,
|
|
169
|
+
modified,
|
|
170
|
+
...inspect(abs, size)
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
walk(root, 0);
|
|
175
|
+
return found.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* @internal The one place a request's path becomes a file path.
|
|
179
|
+
*
|
|
180
|
+
* `.json` under the project, nothing else — the file browser offers every JSON
|
|
181
|
+
* and the loader decides, but a dev server still must not be a way to read
|
|
182
|
+
* `.env` or somebody's key file. Content is gated separately: a JSON that is
|
|
183
|
+
* not a scene is never sent to the page, only the reason it is not one.
|
|
184
|
+
*/
|
|
185
|
+
function resolveSceneFile(root, rel) {
|
|
186
|
+
if (typeof rel !== "string" || !rel.endsWith(".json")) return null;
|
|
187
|
+
const abs = normalize(resolve(root, rel));
|
|
188
|
+
return abs.startsWith(root.endsWith(sep) ? root : root + sep) ? abs : null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Same-origin assertion for state-changing requests.
|
|
192
|
+
*
|
|
193
|
+
* A dev server is reachable from any page the developer happens to have open:
|
|
194
|
+
* `fetch('http://localhost:5173/api/scenes', {method:'POST', ...})` from
|
|
195
|
+
* evil.example needs no CORS permission to be SENT — only to be read — so
|
|
196
|
+
* without this a visited page can write `*.scene.json` files into the repo.
|
|
197
|
+
* Browsers attach `Origin` to every POST/PUT (including cross-site form posts,
|
|
198
|
+
* the one path that used to omit it), so requiring it to match the host we were
|
|
199
|
+
* addressed as costs a real editor nothing. A request with no `Origin` at all is
|
|
200
|
+
* not a browser, and a non-browser client has no CSRF exposure to exploit —
|
|
201
|
+
* same rule the `incanto-editor` server uses.
|
|
202
|
+
*/
|
|
203
|
+
function originAllowed(req) {
|
|
204
|
+
const raw = req.headers.origin;
|
|
205
|
+
const origin = Array.isArray(raw) ? raw[0] : raw;
|
|
206
|
+
if (!origin) return true;
|
|
207
|
+
const rawHost = req.headers.host;
|
|
208
|
+
try {
|
|
209
|
+
return new URL(origin).host === (Array.isArray(rawHost) ? rawHost[0] : rawHost ?? "");
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function json(res, status, body) {
|
|
215
|
+
res.statusCode = status;
|
|
216
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
217
|
+
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
218
|
+
}
|
|
219
|
+
function readBody(req) {
|
|
220
|
+
return new Promise((done, fail) => {
|
|
221
|
+
let raw = "";
|
|
222
|
+
req.on("data", (chunk) => {
|
|
223
|
+
raw += String(chunk);
|
|
224
|
+
if (raw.length > 64e6) fail(/* @__PURE__ */ new Error("scene too large"));
|
|
225
|
+
});
|
|
226
|
+
req.on("end", () => done(raw));
|
|
227
|
+
req.on("error", () => fail(/* @__PURE__ */ new Error("request failed")));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** @internal Exported for tests — `GET`/`POST /api/scenes`. */
|
|
231
|
+
async function serveSceneList(req, res, root) {
|
|
232
|
+
const base = normalize(resolve(root));
|
|
233
|
+
if (req.method === void 0 || req.method === "GET") return json(res, 200, discoverScenes(base));
|
|
234
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
235
|
+
if (!originAllowed(req)) return json(res, 403, { error: "cross-origin write refused" });
|
|
236
|
+
let body;
|
|
237
|
+
try {
|
|
238
|
+
body = JSON.parse(await readBody(req));
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return json(res, 400, { error: `bad request body: ${message(error)}` });
|
|
241
|
+
}
|
|
242
|
+
const wanted = typeof body.path === "string" && body.path && !body.path.endsWith(".json") ? `${body.path}.scene.json` : body.path;
|
|
243
|
+
const abs = resolveSceneFile(base, wanted);
|
|
244
|
+
if (!abs) return json(res, 400, { error: "path must be a .json file inside the project" });
|
|
245
|
+
if (existsSync(abs)) return json(res, 409, { error: `already exists: ${String(wanted)}` });
|
|
246
|
+
const name = basename(abs).replace(/\.scene\.json$|\.json$/, "") || "Scene";
|
|
247
|
+
try {
|
|
248
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
249
|
+
writeFileSync(abs, `${JSON.stringify({
|
|
250
|
+
format: 1,
|
|
251
|
+
type: "scene",
|
|
252
|
+
dimension: "3d",
|
|
253
|
+
name,
|
|
254
|
+
root: {
|
|
255
|
+
name,
|
|
256
|
+
type: "Node3D"
|
|
257
|
+
}
|
|
258
|
+
}, null, 2)}\n`);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return json(res, 400, { error: message(error) });
|
|
261
|
+
}
|
|
262
|
+
return json(res, 201, {
|
|
263
|
+
rel: relative(base, abs).split(sep).join("/"),
|
|
264
|
+
abs,
|
|
265
|
+
size: 0,
|
|
266
|
+
modified: 0
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** @internal Exported for tests — `GET`/`PUT /api/scene?file=`. */
|
|
270
|
+
async function serveSceneFile(req, res, root) {
|
|
271
|
+
const base = normalize(resolve(root));
|
|
272
|
+
const file = new URL(req.url ?? "/", "http://localhost").searchParams.get("file");
|
|
273
|
+
const abs = resolveSceneFile(base, file);
|
|
274
|
+
if (!abs) return json(res, 400, { error: "?file= must be a .json file inside the project" });
|
|
275
|
+
if (req.method === void 0 || req.method === "GET") {
|
|
276
|
+
let text;
|
|
277
|
+
let parsed;
|
|
278
|
+
try {
|
|
279
|
+
text = readFileSync(abs, "utf-8");
|
|
280
|
+
parsed = JSON.parse(text);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return json(res, 422, { error: `${file}: ${message(error)}` });
|
|
283
|
+
}
|
|
284
|
+
const facts = sceneFacts(parsed);
|
|
285
|
+
if (!facts.ok) return json(res, 422, {
|
|
286
|
+
error: `${file} is not an incanto scene — ${facts.why}.`,
|
|
287
|
+
notScene: true
|
|
288
|
+
});
|
|
289
|
+
return json(res, 200, text);
|
|
290
|
+
}
|
|
291
|
+
if (req.method !== "PUT") return json(res, 405, { error: "method not allowed" });
|
|
292
|
+
if (!originAllowed(req)) return json(res, 403, { error: "cross-origin write refused" });
|
|
293
|
+
let raw;
|
|
294
|
+
try {
|
|
295
|
+
raw = await readBody(req);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return json(res, 400, { error: message(error) });
|
|
298
|
+
}
|
|
299
|
+
let parsed;
|
|
300
|
+
try {
|
|
301
|
+
parsed = JSON.parse(raw);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return json(res, 422, { error: `not JSON — the file was NOT written: ${message(error)}` });
|
|
304
|
+
}
|
|
305
|
+
const shape = sceneFacts(parsed);
|
|
306
|
+
if (!shape.ok) return json(res, 422, { error: `not an incanto scene — ${shape.why}. The file was NOT written.` });
|
|
307
|
+
try {
|
|
308
|
+
writeFileSync(abs, raw.endsWith("\n") ? raw : `${raw}\n`);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return json(res, 400, { error: message(error) });
|
|
311
|
+
}
|
|
312
|
+
return json(res, 200, {
|
|
313
|
+
ok: true,
|
|
314
|
+
output: abs
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function message(error) {
|
|
318
|
+
return error instanceof Error ? error.message : String(error);
|
|
319
|
+
}
|
|
61
320
|
/**
|
|
62
321
|
* Serve the agent8 asset catalog at `/api/library` on the dev server.
|
|
63
322
|
*
|
|
@@ -146,4 +405,4 @@ function libraryItem(row) {
|
|
|
146
405
|
};
|
|
147
406
|
}
|
|
148
407
|
//#endregion
|
|
149
|
-
export { incantoLibrary, incantoScenes, serveLibrary };
|
|
408
|
+
export { discoverScenes, incantoLibrary, incantoScenes, resolveSceneFile, sceneFacts, serveLibrary, serveSceneFile, serveSceneList };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e}from"./index-
|
|
1
|
+
import{n as e}from"./index-C9fb5QcT.js";async function t(t){return new n((await e(()=>import(`./GameServer-C56iOUgF.js`),[],import.meta.url)).GameServer,t)}var n=class{raw;active=new Map;reconnecting=!1;disposed=!1;constructor(e,t){this.raw=new e({...t})}get account(){return this.raw.account}get connected(){return this.raw.connected}connect(){return this.raw.connected?Promise.resolve(!0):(this.disposed=!1,this.rawConnect())}rawConnect(){return this.raw.connect({onDisconnect:()=>void this.reconnect()})}disconnect(){this.disposed=!0;for(let e of this.active.values())e.off();return this.active.clear(),this.raw.disconnect()}remoteFunction(e,t,n){return this.raw.remoteFunction(e,t,n)}track(e){let t=Symbol(`sub`),n={make:e,off:e()};return this.active.set(t,n),()=>{n.off(),this.active.delete(t)}}async reconnect(){if(!this.disposed&&!this.reconnecting){this.reconnecting=!0;try{await this.rawConnect();for(let e of this.active.values())e.off(),e.off=e.make()}finally{this.reconnecting=!1}}}subscribeRoomState(e,t){return this.track(()=>this.raw.subscribeRoomState(e,t))}subscribeRoomMyState(e,t){return this.track(()=>this.raw.subscribeRoomMyState(e,t))}subscribeRoomAllUserStates(e,t){return this.track(()=>this.raw.subscribeRoomAllUserStates(e,e=>{let n={};for(let t of e??[]){if(!t||typeof t.account!=`string`||t.__leaved)continue;let{account:e,__updated:r,__leaved:i,...a}=t;n[e]=a}t(n)}))}subscribeRoomCollection(e,t,n){return this.track(()=>this.raw.subscribeRoomCollection(e,t,({items:e})=>{let t={};for(let n of e??[])n&&typeof n.__id==`string`&&(t[n.__id]=n);n(t)}))}onRoomMessage(e,t,n){return this.track(()=>this.raw.onRoomMessage(e,t,n))}onRoomUserJoin(e,t){return this.track(()=>this.raw.onRoomUserJoin(e,t))}onRoomUserLeave(e,t){return this.track(()=>this.raw.onRoomUserLeave(e,t))}subscribeGlobalState(e){return this.track(()=>this.raw.subscribeGlobalState(e))}subscribeGlobalMyState(e){return this.track(()=>this.raw.subscribeGlobalMyState(e))}subscribeGlobalUserState(e,t){return this.track(()=>this.raw.subscribeGlobalUserState(e,t))}subscribeGlobalCollection(e,t){return this.track(()=>this.raw.subscribeGlobalCollection(e,({items:e})=>{let n={};for(let t of e??[])t&&typeof t.__id==`string`&&(n[t.__id]=t);t(n)}))}subscribeAsset(e,t){return this.track(()=>this.raw.subscribeAsset(e,t))}onGlobalMessage(e,t){return this.track(()=>this.raw.onGlobalMessage(e,t))}};export{t as createAgent8Server};
|