incanto 0.1.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/LICENSE +30 -0
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES.md +88 -0
- package/assets/audio/attacked.mp3 +0 -0
- package/assets/audio/explosion.mp3 +0 -0
- package/assets/audio/gold_loot.mp3 +0 -0
- package/assets/audio/heal.mp3 +0 -0
- package/assets/audio/hit_metal_bang.mp3 +0 -0
- package/assets/audio/ice_spear.mp3 +0 -0
- package/assets/audio/monster_died.mp3 +0 -0
- package/assets/audio/slash.mp3 +0 -0
- package/assets/audio/smite.mp3 +0 -0
- package/assets/audio/spells_cast.mp3 +0 -0
- package/assets/audio/ui_click.wav +0 -0
- package/assets/audio/walk.mp3 +0 -0
- package/assets/catalog.json +390 -0
- package/assets/characters/2dbasic.json +41 -0
- package/assets/characters/2dbasic.png +0 -0
- package/assets/characters/ghost.json +46 -0
- package/assets/characters/ghost.png +0 -0
- package/assets/characters/goblin.json +40 -0
- package/assets/characters/goblin.png +0 -0
- package/assets/characters/medieval-knight.json +41 -0
- package/assets/characters/medieval-knight.png +0 -0
- package/assets/effects/swoosh.png +0 -0
- package/assets/items/box.png +0 -0
- package/assets/items/buff_potion.png +0 -0
- package/assets/items/coin.png +0 -0
- package/assets/items/gem.png +0 -0
- package/assets/items/gold.png +0 -0
- package/assets/items/hp_potion.png +0 -0
- package/assets/items/locked_item_box.png +0 -0
- package/assets/items/map.png +0 -0
- package/assets/items/resurrection_potion.png +0 -0
- package/assets/items/super_box.png +0 -0
- package/assets/items/trap.png +0 -0
- package/assets/tiles/floor00.jpg +0 -0
- package/assets/tiles/minecraft-tiles.png +0 -0
- package/assets/tiles/wall00.jpg +0 -0
- package/assets/vegetation/ash_color.png +0 -0
- package/assets/vegetation/aspen_color.png +0 -0
- package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
- package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
- package/assets/vegetation/ground/dirt_color.jpg +0 -0
- package/assets/vegetation/ground/dirt_normal.jpg +0 -0
- package/assets/vegetation/ground/grass.jpg +0 -0
- package/assets/vegetation/oak_color.png +0 -0
- package/assets/vegetation/pine_color.png +0 -0
- package/bin/incanto-assets.mjs +107 -0
- package/bin/incanto-check.mjs +107 -0
- package/bin/incanto-editor.mjs +343 -0
- package/bin/incanto-env.mjs +144 -0
- package/bin/incanto-model.mjs +296 -0
- package/bin/incanto-play.mjs +219 -0
- package/bin/incanto-skills.mjs +71 -0
- package/dist/2d.d.ts +642 -0
- package/dist/2d.js +44 -0
- package/dist/3d.d.ts +1860 -0
- package/dist/3d.js +5 -0
- package/dist/agent8-DzU2fFyH.js +129 -0
- package/dist/audio-player-DqUR3XFs.d.ts +110 -0
- package/dist/behavior-BAQq7HGM.d.ts +851 -0
- package/dist/create-game-BdjpTHrW.js +1725 -0
- package/dist/create-game-CZHROKcT.js +527 -0
- package/dist/debug-draw-CZmOYjL2.js +13 -0
- package/dist/debug.d.ts +66 -0
- package/dist/debug.js +658 -0
- package/dist/duplicate-DP2WPYom.js +22 -0
- package/dist/env.d.ts +430 -0
- package/dist/env.js +3152 -0
- package/dist/errors-BMFaY68Q.d.ts +33 -0
- package/dist/errors-BpWbnbb_.js +13 -0
- package/dist/gameplay-Ccruc3Wd.js +1501 -0
- package/dist/gameplay.d.ts +543 -0
- package/dist/gameplay.js +2 -0
- package/dist/heightmap-CroQPEER.js +185 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +62 -0
- package/dist/json-BLk7H2Qa.js +30 -0
- package/dist/loader-CGs_G-r0.js +919 -0
- package/dist/loader-Mo0KghCv.d.ts +41 -0
- package/dist/net.d.ts +427 -0
- package/dist/net.js +772 -0
- package/dist/noise-CGUMx44x.js +82 -0
- package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
- package/dist/particle-sim-DYuSUxvK.js +1319 -0
- package/dist/physics-2d-KuMWPTf6.js +288 -0
- package/dist/physics-3d-Dl67vOLT.js +434 -0
- package/dist/react.d.ts +65 -0
- package/dist/react.js +209 -0
- package/dist/register-BuUV1_KB.js +561 -0
- package/dist/register-CNlYAS1_.js +10634 -0
- package/dist/register-DPEV9_9t.js +851 -0
- package/dist/register-Dasmnurl.js +374 -0
- package/dist/registry-BVJ2HbCn.js +132 -0
- package/dist/rng-DP-SR7eg.js +38 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/schema-CcoWb32N.d.ts +104 -0
- package/dist/test.d.ts +158 -0
- package/dist/test.js +275 -0
- package/dist/touch-031PxtCR.js +208 -0
- package/dist/vite.d.ts +26 -0
- package/dist/vite.js +57 -0
- package/editor/assets/GameServer-C56iOUgF.js +1 -0
- package/editor/assets/agent8-Bp7QFI7v.js +1 -0
- package/editor/assets/index-DF3tMeKJ.css +1 -0
- package/editor/assets/index-Dl2pjA8e.js +7365 -0
- package/editor/assets/rapier-CEuLKeCu.js +1 -0
- package/editor/assets/rapier-DE6a0vmv.js +1 -0
- package/editor/index.html +169 -0
- package/package.json +97 -0
- package/schemas/scene.schema.json +4254 -0
- package/skills/README.md +9 -0
- package/skills/incanto-3d-character.md +229 -0
- package/skills/incanto-3d-models.md +151 -0
- package/skills/incanto-assets.md +118 -0
- package/skills/incanto-audio.md +309 -0
- package/skills/incanto-behaviors-and-scripts.md +169 -0
- package/skills/incanto-building-2d-games.md +242 -0
- package/skills/incanto-building-3d-games.md +245 -0
- package/skills/incanto-editor.md +163 -0
- package/skills/incanto-environment.md +743 -0
- package/skills/incanto-gameplay-behaviors.md +707 -0
- package/skills/incanto-multiplayer.md +264 -0
- package/skills/incanto-node-reference.md +797 -0
- package/skills/incanto-physics-and-input.md +164 -0
- package/skills/incanto-scene-json-authoring.md +325 -0
- package/skills/incanto-verifying-your-game.md +191 -0
- package/skills/incanto-web-integration.md +96 -0
- package/templates/agent8-server.js +84 -0
- package/templates/agent8-server.ts +138 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* incanto-editor — serve the scene-composer page, rendering with the INSTALLED
|
|
4
|
+
* engine version (the editor bundle ships in this package).
|
|
5
|
+
*
|
|
6
|
+
* npx incanto-editor # PROJECT mode: discovers *.scene.json
|
|
7
|
+
* npx incanto-editor src/game.scene.json # SINGLE mode: one fixed file
|
|
8
|
+
* npx incanto-editor level.scene.json --output build/level.scene.json --port 5179 --host 0.0.0.0
|
|
9
|
+
*
|
|
10
|
+
* API (the page can only address *.scene.json files under the launch directory):
|
|
11
|
+
* GET /api/meta → { version, mode, root, input?, output? }
|
|
12
|
+
* GET /api/scenes → [{ rel, abs }] (project mode)
|
|
13
|
+
* POST /api/scenes → { path } creates a minimal scene (project mode)
|
|
14
|
+
* GET /api/scene[?file=] → scene JSON (single mode ignores ?file=)
|
|
15
|
+
* PUT /api/scene[?file=] → validates the scene shape, writes the file
|
|
16
|
+
*
|
|
17
|
+
* Security: on loopback binds, the Host header must be loopback (defeats DNS
|
|
18
|
+
* rebinding); writes additionally require Origin (when present) to match the
|
|
19
|
+
* request host. Bodies are capped at 8MB. Addressable paths are root-bounded
|
|
20
|
+
* and must end in .scene.json.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { createServer } from 'node:http';
|
|
24
|
+
import { basename, dirname, extname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const EDITOR_DIR = join(PKG_ROOT, 'editor');
|
|
29
|
+
|
|
30
|
+
// Biome-compatible JSON layout — same algorithm as the editor's
|
|
31
|
+
// src/format-json.ts (inline when the one-line form fits 80 cols).
|
|
32
|
+
function formatSceneJson(value) {
|
|
33
|
+
const render = (v, depth) => {
|
|
34
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
35
|
+
const inline = inlineForm(v);
|
|
36
|
+
if (depth * 2 + inline.length <= 80) return inline;
|
|
37
|
+
const pad = ' '.repeat(depth + 1);
|
|
38
|
+
const close = ' '.repeat(depth);
|
|
39
|
+
if (Array.isArray(v)) {
|
|
40
|
+
if (v.length === 0) return '[]';
|
|
41
|
+
return `[\n${v.map((x) => pad + render(x, depth + 1)).join(',\n')}\n${close}]`;
|
|
42
|
+
}
|
|
43
|
+
const entries = Object.entries(v);
|
|
44
|
+
if (entries.length === 0) return '{}';
|
|
45
|
+
const items = entries.map(([k, x]) => `${pad}${JSON.stringify(k)}: ${render(x, depth + 1)}`);
|
|
46
|
+
return `{\n${items.join(',\n')}\n${close}}`;
|
|
47
|
+
};
|
|
48
|
+
const inlineForm = (v) => {
|
|
49
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
50
|
+
if (Array.isArray(v)) return v.length === 0 ? '[]' : `[${v.map(inlineForm).join(', ')}]`;
|
|
51
|
+
const entries = Object.entries(v);
|
|
52
|
+
if (entries.length === 0) return '{}';
|
|
53
|
+
return `{ ${entries.map(([k, x]) => `${JSON.stringify(k)}: ${inlineForm(x)}`).join(', ')} }`;
|
|
54
|
+
};
|
|
55
|
+
return `${render(value, 0)}\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseArgs(argv) {
|
|
59
|
+
const args = { input: undefined, output: undefined, port: 5179, host: '127.0.0.1' };
|
|
60
|
+
for (let i = 0; i < argv.length; i++) {
|
|
61
|
+
const a = argv[i];
|
|
62
|
+
if (a === '--output') args.output = argv[++i];
|
|
63
|
+
else if (a === '--port') args.port = Number(argv[++i]);
|
|
64
|
+
else if (a === '--host') args.host = argv[++i];
|
|
65
|
+
else if (a === '--help' || a === '-h') args.help = true;
|
|
66
|
+
else if (!a.startsWith('-') && !args.input) args.input = a;
|
|
67
|
+
else {
|
|
68
|
+
console.error(`unknown argument: ${a}`);
|
|
69
|
+
args.error = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return args;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const args = parseArgs(process.argv.slice(2));
|
|
76
|
+
if (args.help || args.error || Number.isNaN(args.port) || (args.output && !args.input)) {
|
|
77
|
+
console.log(`Usage: npx incanto-editor [scene.json] [--output <path>] [--port 5179] [--host 127.0.0.1]
|
|
78
|
+
|
|
79
|
+
Without a file: PROJECT mode — discovers every *.scene.json under the current
|
|
80
|
+
directory (a picker opens; a single scene auto-opens; new scenes can be created).
|
|
81
|
+
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.`);
|
|
83
|
+
process.exit(args.help && !args.error ? 0 : 1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const MODE = args.input ? 'single' : 'project';
|
|
87
|
+
const ROOT = resolve('.');
|
|
88
|
+
const INPUT = args.input ? resolve(args.input) : null;
|
|
89
|
+
const OUTPUT = args.input ? resolve(args.output ?? args.input) : null;
|
|
90
|
+
const VERSION = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
|
|
91
|
+
|
|
92
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage']);
|
|
93
|
+
|
|
94
|
+
function discoverScenes() {
|
|
95
|
+
const found = [];
|
|
96
|
+
const walk = (dir, depth) => {
|
|
97
|
+
if (depth > 6) return;
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const e of entries) {
|
|
105
|
+
if (e.isDirectory()) {
|
|
106
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), depth + 1);
|
|
107
|
+
} else if (e.name.endsWith('.scene.json')) {
|
|
108
|
+
const abs = join(dir, e.name);
|
|
109
|
+
found.push({ rel: relative(ROOT, abs), abs });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(ROOT, 0);
|
|
114
|
+
return found.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Root-bounded, *.scene.json-only — the page can never address anything else. */
|
|
118
|
+
function resolveSceneFile(rel) {
|
|
119
|
+
if (typeof rel !== 'string' || !rel.endsWith('.scene.json')) return null;
|
|
120
|
+
const abs = normalize(resolve(ROOT, rel));
|
|
121
|
+
if (abs !== ROOT && !abs.startsWith(ROOT + sep)) return null;
|
|
122
|
+
return abs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function targetFor(url) {
|
|
126
|
+
if (MODE === 'single') return { input: INPUT, output: OUTPUT };
|
|
127
|
+
const abs = resolveSceneFile(url.searchParams.get('file'));
|
|
128
|
+
return abs ? { input: abs, output: abs } : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const MIME = {
|
|
132
|
+
'.html': 'text/html; charset=utf-8',
|
|
133
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
134
|
+
'.css': 'text/css; charset=utf-8',
|
|
135
|
+
'.json': 'application/json; charset=utf-8',
|
|
136
|
+
'.svg': 'image/svg+xml',
|
|
137
|
+
'.png': 'image/png',
|
|
138
|
+
'.wasm': 'application/wasm',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function send(res, status, body, type = 'application/json; charset=utf-8') {
|
|
142
|
+
res.writeHead(status, { 'content-type': type });
|
|
143
|
+
res.end(body);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const LOOPBACK_BIND = args.host === '127.0.0.1' || args.host === 'localhost' || args.host === '::1';
|
|
147
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
148
|
+
const MAX_BODY = 8 * 1024 * 1024;
|
|
149
|
+
|
|
150
|
+
function hostAllowed(req) {
|
|
151
|
+
// Loopback bind: the Host header must be loopback too — a DNS-rebound page
|
|
152
|
+
// (attacker.com → 127.0.0.1) arrives with Host: attacker.com and is rejected.
|
|
153
|
+
if (!LOOPBACK_BIND) return true; // non-loopback (containers): host names vary by network
|
|
154
|
+
const host = (req.headers.host ?? '').replace(/:\d+$/, '');
|
|
155
|
+
return LOOPBACK_HOSTS.has(host);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function originAllowed(req) {
|
|
159
|
+
// Same-origin assertion for state-changing requests: when a browser sends an
|
|
160
|
+
// Origin header it must match the host we were addressed as.
|
|
161
|
+
const origin = req.headers.origin;
|
|
162
|
+
if (!origin) return true;
|
|
163
|
+
try {
|
|
164
|
+
return new URL(origin).host === (req.headers.host ?? '');
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
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
|
+
function readBody(req, res, onJson) {
|
|
183
|
+
const chunks = [];
|
|
184
|
+
let received = 0;
|
|
185
|
+
req.on('data', (c) => {
|
|
186
|
+
received += c.length;
|
|
187
|
+
if (received > MAX_BODY) {
|
|
188
|
+
send(res, 413, JSON.stringify({ error: 'body too large' }));
|
|
189
|
+
req.destroy();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
chunks.push(c);
|
|
193
|
+
});
|
|
194
|
+
req.on('end', () => {
|
|
195
|
+
if (res.writableEnded) return;
|
|
196
|
+
try {
|
|
197
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
198
|
+
onJson(JSON.parse(raw), raw);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
send(res, 400, JSON.stringify({ error: String(error.message ?? error) }));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const server = createServer((req, res) => {
|
|
206
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
207
|
+
|
|
208
|
+
if (!hostAllowed(req)) {
|
|
209
|
+
return send(res, 403, JSON.stringify({ error: 'forbidden host' }));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (url.pathname === '/api/meta') {
|
|
213
|
+
return send(
|
|
214
|
+
res,
|
|
215
|
+
200,
|
|
216
|
+
JSON.stringify({ version: VERSION, mode: MODE, root: ROOT, input: INPUT, output: OUTPUT }),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (url.pathname === '/api/scenes') {
|
|
221
|
+
if (MODE !== 'project') {
|
|
222
|
+
return send(res, 400, JSON.stringify({ error: 'scene listing is project-mode only' }));
|
|
223
|
+
}
|
|
224
|
+
if (req.method === 'GET') {
|
|
225
|
+
return send(res, 200, JSON.stringify(discoverScenes()));
|
|
226
|
+
}
|
|
227
|
+
if (req.method === 'POST') {
|
|
228
|
+
if (!originAllowed(req)) {
|
|
229
|
+
return send(res, 403, JSON.stringify({ error: 'cross-origin write rejected' }));
|
|
230
|
+
}
|
|
231
|
+
readBody(req, res, (body) => {
|
|
232
|
+
const abs = resolveSceneFile(body?.path);
|
|
233
|
+
if (!abs) {
|
|
234
|
+
return send(
|
|
235
|
+
res,
|
|
236
|
+
400,
|
|
237
|
+
JSON.stringify({ error: 'path must be a *.scene.json inside the project' }),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (existsSync(abs)) {
|
|
241
|
+
return send(res, 409, JSON.stringify({ error: `already exists: ${body.path}` }));
|
|
242
|
+
}
|
|
243
|
+
const name = basename(abs).replace(/\.scene\.json$/, '') || 'Scene';
|
|
244
|
+
const scene = {
|
|
245
|
+
format: 1,
|
|
246
|
+
type: 'scene',
|
|
247
|
+
dimension: '2d',
|
|
248
|
+
name,
|
|
249
|
+
root: { name, type: 'Node2D' },
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
253
|
+
writeFileSync(abs, formatSceneJson(scene));
|
|
254
|
+
return send(res, 201, JSON.stringify({ rel: relative(ROOT, abs), abs }));
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return send(res, 400, JSON.stringify({ error: String(error.message ?? error) }));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
return send(res, 405, JSON.stringify({ error: 'method not allowed' }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (url.pathname === '/api/scene') {
|
|
265
|
+
const target = targetFor(url);
|
|
266
|
+
if (!target) {
|
|
267
|
+
return send(
|
|
268
|
+
res,
|
|
269
|
+
400,
|
|
270
|
+
JSON.stringify({ error: '?file= must be a *.scene.json inside the project' }),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (req.method === 'GET') {
|
|
274
|
+
try {
|
|
275
|
+
const text = readFileSync(target.input, 'utf-8');
|
|
276
|
+
JSON.parse(text); // must at least be JSON before the page gets it
|
|
277
|
+
return send(res, 200, text);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return send(res, 422, JSON.stringify({ error: String(error.message ?? error) }));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (req.method === 'PUT') {
|
|
283
|
+
if (!originAllowed(req)) {
|
|
284
|
+
return send(res, 403, JSON.stringify({ error: 'cross-origin write rejected' }));
|
|
285
|
+
}
|
|
286
|
+
readBody(req, res, (json, raw) => {
|
|
287
|
+
if (!isSceneShaped(json)) {
|
|
288
|
+
return send(
|
|
289
|
+
res,
|
|
290
|
+
422,
|
|
291
|
+
JSON.stringify({
|
|
292
|
+
error:
|
|
293
|
+
'not an incanto scene — required: format: 1, type: "scene", name: string, root: object. The file was NOT written.',
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
// Write the client's bytes verbatim — the editor formats scenes
|
|
299
|
+
// biome-compatibly; re-stringifying here would undo that.
|
|
300
|
+
writeFileSync(target.output, raw.endsWith('\n') ? raw : `${raw}\n`);
|
|
301
|
+
return send(res, 200, JSON.stringify({ ok: true, output: target.output }));
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return send(res, 400, JSON.stringify({ error: String(error.message ?? error) }));
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
return send(res, 405, JSON.stringify({ error: 'method not allowed' }));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// static editor assets (path-traversal guarded)
|
|
312
|
+
const rel = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
313
|
+
const file = normalize(join(EDITOR_DIR, rel));
|
|
314
|
+
if (file !== EDITOR_DIR && !file.startsWith(EDITOR_DIR + sep)) {
|
|
315
|
+
return send(res, 403, JSON.stringify({ error: 'forbidden' }));
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const body = readFileSync(file);
|
|
319
|
+
return send(res, 200, body, MIME[extname(file)] ?? 'application/octet-stream');
|
|
320
|
+
} catch {
|
|
321
|
+
return send(res, 404, JSON.stringify({ error: `not found: ${rel}` }));
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
server.listen(args.port, args.host, () => {
|
|
326
|
+
console.log(`incanto-editor v${VERSION}`);
|
|
327
|
+
if (!LOOPBACK_BIND) {
|
|
328
|
+
console.warn(
|
|
329
|
+
` WARNING: bound to ${args.host} — anyone who can reach this port can read/write scene files. Use only on trusted networks (e.g. inside a container).`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (MODE === 'single') {
|
|
333
|
+
console.log(` scene: ${INPUT}${OUTPUT === INPUT ? '' : `\n output: ${OUTPUT}`}`);
|
|
334
|
+
} else {
|
|
335
|
+
const scenes = discoverScenes();
|
|
336
|
+
console.log(
|
|
337
|
+
` project: ${ROOT} (${scenes.length} scene file${scenes.length === 1 ? '' : 's'})`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
console.log(
|
|
341
|
+
` url: http://${args.host === '0.0.0.0' ? 'localhost' : args.host}:${args.port}/`,
|
|
342
|
+
);
|
|
343
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* incanto-env — deterministic environment generators on the command line.
|
|
4
|
+
* Emits scene-insertable NodeJson; the same --seed always emits the same
|
|
5
|
+
* JSON (keep the seed in your scene notes so levels are reproducible).
|
|
6
|
+
*
|
|
7
|
+
* Subcommands and flags come straight from the GENERATORS metadata in the
|
|
8
|
+
* built library, so `--list` and the params can never drift from the code:
|
|
9
|
+
*
|
|
10
|
+
* npx incanto-env --list every generator + params
|
|
11
|
+
* npx incanto-env <generator> --seed 42 [--<param> <value> …] [--json]
|
|
12
|
+
* npx incanto-env terrain --seed 42 --theme meadow
|
|
13
|
+
* … --into src/game.scene.json [--at Root/Level] insert instead of print
|
|
14
|
+
*
|
|
15
|
+
* generateScatter is library-only (`import {generateScatter} from
|
|
16
|
+
* 'incanto/env'`) — its item templates don't fit CLI flags.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
|
|
24
|
+
// ---- the catalog (via the BUILT library — exactly what consumers run) ---------------
|
|
25
|
+
const env = await import(pathToFileURL(join(PKG, 'dist', 'env.js')).href);
|
|
26
|
+
|
|
27
|
+
const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
28
|
+
|
|
29
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
30
|
+
|
|
31
|
+
if (!command || command === '--help' || command === '-h' || command === '--list') {
|
|
32
|
+
const rows = Object.entries(env.GENERATORS).map(([name, meta]) => {
|
|
33
|
+
const params = Object.entries(meta.params)
|
|
34
|
+
.map(([param, def]) =>
|
|
35
|
+
def.type === 'boolean'
|
|
36
|
+
? `[--${kebab(param)}]`
|
|
37
|
+
: `[--${kebab(param)} ${JSON.stringify(def.default)}]`,
|
|
38
|
+
)
|
|
39
|
+
.join(' ');
|
|
40
|
+
return ` ${name.padEnd(12)} ${meta.dimension} ${meta.description}\n --seed <n> ${params}`;
|
|
41
|
+
});
|
|
42
|
+
console.log(`Usage:
|
|
43
|
+
incanto-env <generator> --seed <n> [--<param> <value> …] [--json]
|
|
44
|
+
incanto-env <generator> --seed <n> --into <scene.json> [--at Root/Level]
|
|
45
|
+
incanto-env --list
|
|
46
|
+
|
|
47
|
+
--seed is REQUIRED — generators are deterministic, the same seed always emits
|
|
48
|
+
the same JSON. scatter is library-only (needs item templates).
|
|
49
|
+
|
|
50
|
+
${rows.join('\n')}`);
|
|
51
|
+
process.exit(command ? 0 : 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const meta = env.GENERATORS[command];
|
|
55
|
+
if (!meta) {
|
|
56
|
+
console.error(
|
|
57
|
+
`unknown command '${command}'. Available: [${Object.keys(env.GENERATORS).join(', ')}] — the old meadow/forest/island/rocks/clouds generators became themes (e.g. terrain --theme meadow); scatter is library-only (needs item templates).`,
|
|
58
|
+
);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- flags (generic, metadata-driven) ------------------------------------------------
|
|
63
|
+
const paramByFlag = new Map();
|
|
64
|
+
const BOOLEAN_FLAGS = new Set(['--json']);
|
|
65
|
+
for (const [param, def] of Object.entries(meta.params)) {
|
|
66
|
+
const flag = `--${kebab(param)}`;
|
|
67
|
+
paramByFlag.set(flag, { param, def });
|
|
68
|
+
if (def.type === 'boolean') BOOLEAN_FLAGS.add(flag);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const flags = new Map();
|
|
72
|
+
for (let i = 0; i < rest.length; i++) {
|
|
73
|
+
const arg = rest[i];
|
|
74
|
+
if (!arg.startsWith('--')) {
|
|
75
|
+
console.error(`unexpected argument '${arg}' — flags look like --seed 42`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
if (BOOLEAN_FLAGS.has(arg)) {
|
|
79
|
+
flags.set(arg, true);
|
|
80
|
+
} else {
|
|
81
|
+
flags.set(arg, rest[++i]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function num(name) {
|
|
86
|
+
if (!flags.has(name)) return undefined;
|
|
87
|
+
const value = Number(flags.get(name));
|
|
88
|
+
if (!Number.isFinite(value)) {
|
|
89
|
+
console.error(`${name} must be a number, got '${flags.get(name)}'`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const seed = num('--seed');
|
|
96
|
+
if (seed === undefined) {
|
|
97
|
+
console.error(
|
|
98
|
+
'--seed is required: generators are deterministic — the same seed always emits the same JSON, ' +
|
|
99
|
+
'so a level is reproducible from its seed alone. Pick any integer and keep it in your scene notes.',
|
|
100
|
+
);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- generate ------------------------------------------------------------------------
|
|
105
|
+
const opts = { seed };
|
|
106
|
+
for (const [flag, value] of flags) {
|
|
107
|
+
if (flag === '--seed' || flag === '--json' || flag === '--into' || flag === '--at') continue;
|
|
108
|
+
const known = paramByFlag.get(flag);
|
|
109
|
+
if (!known) {
|
|
110
|
+
console.error(
|
|
111
|
+
`unknown flag '${flag}' for ${command}. Valid: [${[...paramByFlag.keys()].join(', ')}]`,
|
|
112
|
+
);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
opts[known.param] = known.def.type === 'number' ? num(flag) : value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let node;
|
|
119
|
+
try {
|
|
120
|
+
node = env.runGenerator(command, opts);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error(e?.message ?? String(e));
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const countNodes = (n) => 1 + (n.children ?? []).reduce((sum, c) => sum + countNodes(c), 0);
|
|
127
|
+
|
|
128
|
+
if (flags.has('--into')) {
|
|
129
|
+
const file = resolve(flags.get('--into'));
|
|
130
|
+
const at = flags.get('--at');
|
|
131
|
+
const scene = JSON.parse(readFileSync(file, 'utf-8'));
|
|
132
|
+
const out = env.insertIntoScene(scene, node, at);
|
|
133
|
+
writeFileSync(file, `${JSON.stringify(out, null, 2)}\n`);
|
|
134
|
+
console.log(
|
|
135
|
+
`inserted '${node.name}' (${countNodes(node)} node(s)) into ${file}${at ? ` at '${at}'` : ''} — seed ${seed}`,
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
console.log(JSON.stringify(node, null, 2));
|
|
139
|
+
if (!flags.has('--json')) {
|
|
140
|
+
console.error(
|
|
141
|
+
`${command}: ${countNodes(node)} node(s), seed ${seed} — pipe to a file or rerun with --into <scene.json>`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|