sindicate 0.12.0 → 0.15.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/CHANGELOG.md +69 -0
- package/docs/plans/kiwi-road-recon.md +97 -0
- package/docs/plans/road-system.md +142 -0
- package/docs/plans/three-cities.md +43 -0
- package/package.json +11 -3
- package/src/core/assets.js +15 -0
- package/src/systems/missions.js +2 -2
- package/src/systems/shatter.js +2 -1
- package/src/ui/dialogue.js +2 -1
- package/src/ui/pauseMenu.js +18 -11
- package/src/ui/saveLoadScreen.js +13 -9
- package/src/ui/theme.js +34 -0
- package/src/world/bridgeSplice.js +176 -0
- package/src/world/design.js +72 -0
- package/src/world/geo.js +53 -0
- package/src/world/heightfield.js +146 -0
- package/src/world/kit.js +924 -0
- package/src/world/permanentWay.js +161 -0
- package/src/world/placement.js +309 -0
- package/src/world/railFormation.js +185 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +270 -0
- package/src/world/roadNetwork.js +118 -0
- package/src/world/roadTerrain.js +263 -0
- package/src/world/waterLevels.js +95 -0
- package/src/world/waterMeshes.js +168 -0
- package/src/world/worldBase.js +493 -0
- package/tools/ExportKiwiRoadMaterials.cs +145 -0
- package/tools/ExportKiwiRoadRefs.cs +94 -0
- package/tools/buildKiwiRoadsPack.mjs +36 -0
- package/tools/designer-mcp.mjs +103 -0
- package/tools/designer.js +316 -0
- package/tools/devPlugin.js +324 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/registry.json +45 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/sindicate-cli.mjs +71 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
package/tools/devPlugin.js
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
// perfSink: process.env.MY_PERF_SINK || '/tmp/mygame-perf.jsonl',
|
|
24
24
|
// })]
|
|
25
25
|
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
const _toolsDir = path.dirname(fileURLToPath(import.meta.url));
|
|
26
30
|
|
|
27
31
|
// Guard for the source-writing endpoints: only same-machine pages may write, and the
|
|
28
32
|
// payload must look like the file it claims to be — an empty/garbage body used to be
|
|
@@ -205,6 +209,188 @@ function editorShotsPlugin({ shotsDir, mapsDir }) {
|
|
|
205
209
|
};
|
|
206
210
|
}
|
|
207
211
|
|
|
212
|
+
// THE PACK IMPORTER (ENGINE_DESIGN §5c) — bring publisher packs (Synty et al.) into the
|
|
213
|
+
// RUNNING GAME's repo. A .unitypackage is a gzip tar of GUID dirs, each holding `pathname`
|
|
214
|
+
// (the original Assets/… path) and `asset` (the bytes) — the parse western's python
|
|
215
|
+
// extractor proved, done here with the OS tar. Assets land in the GAME (public/assets/
|
|
216
|
+
// packs/<name>/), never in Sindicate: the engine stays content-free and licenses stay
|
|
217
|
+
// per-project. Animation FBXs route through accentimation-converter when it's present.
|
|
218
|
+
export function packImporterPlugin({ stagingDir = '_staging', packsDir = 'public/assets/packs', converterPath = '../accentimation-converter' } = {}) {
|
|
219
|
+
// category rules from the 85-pack survey (135k paths): newer packs live under
|
|
220
|
+
// Assets/Synty/<Pack>/, older under Assets/<Pack>/Models/; Convex/Collision dirs hold
|
|
221
|
+
// collision meshes (not display models); /Animations/ FBXs are converter fodder;
|
|
222
|
+
// .prefab/.asset/.mat/.cs and friends are Unity-only noise.
|
|
223
|
+
const TEXTURE_RE = /\.(png|jpg|jpeg|tga)$/i;
|
|
224
|
+
const MODEL_RE = /\.(fbx|glb|gltf|obj)$/i;
|
|
225
|
+
const ANIM_HINT = /(^|\/)animations?\//i;
|
|
226
|
+
const COLLISION_RE = /(^|\/)(convex|collision|collisionconvex)\//i;
|
|
227
|
+
const isAnim = (p) => (ANIM_HINT.test(p) && /\.fbx$/i.test(p)) || /(^|\/)A_[^/]+\.fbx$/i.test(p);
|
|
228
|
+
|
|
229
|
+
function inventory(dir) {
|
|
230
|
+
const out = { models: [], textures: [], animations: [], collision: [], other: [] };
|
|
231
|
+
const walk = (d, rel = '') => {
|
|
232
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
233
|
+
if (e.name.startsWith('_')) continue; // working dirs (_anims) are not pack content
|
|
234
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
235
|
+
if (e.isDirectory()) walk(path.join(d, e.name), r);
|
|
236
|
+
else if (isAnim(r)) out.animations.push(r);
|
|
237
|
+
else if (COLLISION_RE.test(r) && MODEL_RE.test(r)) out.collision.push(r);
|
|
238
|
+
else if (MODEL_RE.test(r)) out.models.push(r);
|
|
239
|
+
else if (TEXTURE_RE.test(r)) out.textures.push(r);
|
|
240
|
+
else out.other.push(r);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
walk(dir);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
name: 'sindicate-pack-importer',
|
|
249
|
+
apply: 'serve',
|
|
250
|
+
configureServer(server) {
|
|
251
|
+
// upload: stream the .unitypackage to disk, tar-extract, resolve pathname map, stage
|
|
252
|
+
server.middlewares.use('/__pack/upload', (req, res) => {
|
|
253
|
+
if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
|
|
254
|
+
const name = (new URL(req.url, 'http://x').searchParams.get('name') || 'pack').replace(/\.unitypackage$/i, '').replace(/[^\w.-]+/g, '_');
|
|
255
|
+
const upDir = path.join(stagingDir, '_uploads');
|
|
256
|
+
fs.mkdirSync(upDir, { recursive: true });
|
|
257
|
+
const tmp = path.join(upDir, `${name}.unitypackage`);
|
|
258
|
+
const ws = fs.createWriteStream(tmp);
|
|
259
|
+
req.pipe(ws);
|
|
260
|
+
ws.on('finish', () => {
|
|
261
|
+
try {
|
|
262
|
+
const raw = path.join(stagingDir, '_raw', name);
|
|
263
|
+
fs.rmSync(raw, { recursive: true, force: true });
|
|
264
|
+
fs.mkdirSync(raw, { recursive: true });
|
|
265
|
+
execFileSync('tar', ['-xzf', tmp, '-C', raw], { stdio: 'pipe' });
|
|
266
|
+
const dest = path.join(stagingDir, name);
|
|
267
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
268
|
+
let staged = 0;
|
|
269
|
+
for (const guid of fs.readdirSync(raw)) {
|
|
270
|
+
const pn = path.join(raw, guid, 'pathname');
|
|
271
|
+
const as = path.join(raw, guid, 'asset');
|
|
272
|
+
if (!fs.existsSync(pn) || !fs.existsSync(as)) continue;
|
|
273
|
+
const orig = fs.readFileSync(pn, 'utf8').split('\n')[0].trim();
|
|
274
|
+
if (!orig || orig.split('/').includes('..')) continue;
|
|
275
|
+
const to = path.join(dest, orig);
|
|
276
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
277
|
+
fs.renameSync(as, to);
|
|
278
|
+
staged++;
|
|
279
|
+
}
|
|
280
|
+
fs.rmSync(raw, { recursive: true, force: true });
|
|
281
|
+
fs.rmSync(tmp, { force: true });
|
|
282
|
+
sendJson(res, 200, { pack: name, staged, inventory: summarize(inventory(dest)) });
|
|
283
|
+
} catch (e) { sendJson(res, 500, { err: String(e).slice(0, 300) }); }
|
|
284
|
+
});
|
|
285
|
+
ws.on('error', (e) => sendJson(res, 500, { err: String(e) }));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const summarize = (inv) => ({ models: inv.models.length, textures: inv.textures.length, animations: inv.animations.length, collision: inv.collision.length, other: inv.other.length });
|
|
289
|
+
// THE CATALOG: registry.json (ships with the engine) + live status per entry —
|
|
290
|
+
// installed (node_modules/<name>/sindicate.json) and packaged (newest
|
|
291
|
+
// *.sindicatepackage in the repo dir, resolved against the Sites root = game's parent)
|
|
292
|
+
server.middlewares.use('/__pack/registry', (req, res) => {
|
|
293
|
+
try {
|
|
294
|
+
const reg = JSON.parse(fs.readFileSync(path.join(_toolsDir, 'registry.json'), 'utf8'));
|
|
295
|
+
const sites = path.resolve('..');
|
|
296
|
+
const out = reg.packages.map((p) => {
|
|
297
|
+
const manPath = path.join('node_modules', p.name, 'sindicate.json');
|
|
298
|
+
let installed = null;
|
|
299
|
+
if (fs.existsSync(manPath)) { try { installed = JSON.parse(fs.readFileSync(manPath, 'utf8')).version; } catch { installed = '?'; } }
|
|
300
|
+
const repoDir = path.join(sites, p.repo ?? '');
|
|
301
|
+
let packageFile = null;
|
|
302
|
+
if (p.repo && fs.existsSync(repoDir)) {
|
|
303
|
+
const c = fs.readdirSync(repoDir).filter((f) => f.endsWith('.sindicatepackage')).sort();
|
|
304
|
+
if (c.length) packageFile = path.join(repoDir, c[c.length - 1]);
|
|
305
|
+
}
|
|
306
|
+
return { ...p, installed, packageFile, repoExists: p.repo ? fs.existsSync(repoDir) : false };
|
|
307
|
+
});
|
|
308
|
+
sendJson(res, 200, { packages: out });
|
|
309
|
+
} catch (e) { sendJson(res, 500, { err: String(e) }); }
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// install straight from the catalog: run the sindicate CLI server-side into THIS game
|
|
313
|
+
server.middlewares.use('/__pack/installAddon', async (req, res) => {
|
|
314
|
+
if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
|
|
315
|
+
try {
|
|
316
|
+
const { file } = JSON.parse((await readBody(req)).toString());
|
|
317
|
+
if (!file || !String(file).endsWith('.sindicatepackage') || !fs.existsSync(file)) return sendJson(res, 400, { err: 'no such package file' });
|
|
318
|
+
const out = execFileSync('node', [path.join(_toolsDir, 'sindicate-cli.mjs'), 'install', file, '--game', '.'], { stdio: 'pipe', timeout: 300000 });
|
|
319
|
+
sendJson(res, 200, { ok: true, log: out.toString().slice(-400) });
|
|
320
|
+
} catch (e) { sendJson(res, 500, { err: String(e.stderr ?? e).slice(0, 300) }); }
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// installed add-ons: every node_modules package that ships a sindicate.json
|
|
324
|
+
server.middlewares.use('/__pack/addons', (req, res) => {
|
|
325
|
+
try {
|
|
326
|
+
const nm = 'node_modules';
|
|
327
|
+
const addons = [];
|
|
328
|
+
if (fs.existsSync(nm)) for (const d of fs.readdirSync(nm)) {
|
|
329
|
+
if (d.startsWith('.')) continue;
|
|
330
|
+
const man = path.join(nm, d, 'sindicate.json');
|
|
331
|
+
if (!fs.existsSync(man)) continue;
|
|
332
|
+
try { addons.push(JSON.parse(fs.readFileSync(man, 'utf8'))); } catch { /* unreadable manifest */ }
|
|
333
|
+
}
|
|
334
|
+
sendJson(res, 200, { addons });
|
|
335
|
+
} catch (e) { sendJson(res, 500, { err: String(e) }); }
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// staged packs + inventories
|
|
339
|
+
server.middlewares.use('/__pack/list', (req, res) => {
|
|
340
|
+
try {
|
|
341
|
+
const packs = fs.existsSync(stagingDir)
|
|
342
|
+
? fs.readdirSync(stagingDir).filter((d) => !d.startsWith('_') && fs.statSync(path.join(stagingDir, d)).isDirectory())
|
|
343
|
+
.map((d) => ({ pack: d, inventory: summarize(inventory(path.join(stagingDir, d))) }))
|
|
344
|
+
: [];
|
|
345
|
+
const imported = fs.existsSync(packsDir)
|
|
346
|
+
? fs.readdirSync(packsDir).filter((d) => fs.existsSync(path.join(packsDir, d, 'pack.json')))
|
|
347
|
+
: [];
|
|
348
|
+
sendJson(res, 200, { packs, imported });
|
|
349
|
+
} catch (e) { sendJson(res, 500, { err: String(e) }); }
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// import: copy models/textures into the game pack dir; cook animations via the converter
|
|
353
|
+
server.middlewares.use('/__pack/import', async (req, res) => {
|
|
354
|
+
if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
|
|
355
|
+
try {
|
|
356
|
+
const { pack, categories = ['models', 'textures', 'animations'] } = JSON.parse((await readBody(req)).toString());
|
|
357
|
+
const src = path.join(stagingDir, String(pack).replace(/[^\w.-]+/g, '_'));
|
|
358
|
+
if (!fs.existsSync(src)) return sendJson(res, 404, { err: 'no such staged pack' });
|
|
359
|
+
const inv = inventory(src);
|
|
360
|
+
const out = path.join(packsDir, pack);
|
|
361
|
+
const copied = { models: 0, textures: 0 };
|
|
362
|
+
const copy = (rel, sub) => {
|
|
363
|
+
const to = path.join(out, sub, path.basename(rel));
|
|
364
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
365
|
+
fs.copyFileSync(path.join(src, rel), to);
|
|
366
|
+
};
|
|
367
|
+
if (categories.includes('models')) for (const r of inv.models) { copy(r, 'models'); copied.models++; }
|
|
368
|
+
if (categories.includes('textures')) for (const r of inv.textures) { copy(r, 'textures'); copied.textures++; }
|
|
369
|
+
let clips = null;
|
|
370
|
+
if (categories.includes('animations') && inv.animations.length) {
|
|
371
|
+
const conv = path.resolve(converterPath, 'bin/accentimation.js');
|
|
372
|
+
if (fs.existsSync(conv)) {
|
|
373
|
+
const animDir = path.join(src, '_anims');
|
|
374
|
+
fs.rmSync(animDir, { recursive: true, force: true });
|
|
375
|
+
fs.mkdirSync(animDir, { recursive: true });
|
|
376
|
+
for (const r of inv.animations) fs.copyFileSync(path.join(src, r), path.join(animDir, path.basename(r)));
|
|
377
|
+
const clipsOut = path.join(out, 'clips');
|
|
378
|
+
try {
|
|
379
|
+
execFileSync('node', [conv, 'convert', animDir, '--out', clipsOut], { stdio: 'pipe', timeout: 300000 });
|
|
380
|
+
clips = fs.readdirSync(clipsOut).filter((f) => f.endsWith('.json')).length;
|
|
381
|
+
} catch (e) { clips = `converter failed: ${String(e.stderr ?? e).slice(0, 200)}`; }
|
|
382
|
+
} else clips = `converter not found at ${conv} — animations staged, not cooked`;
|
|
383
|
+
}
|
|
384
|
+
const manifest = { name: pack, importedAt: new Date().toISOString(), counts: { ...copied, clips }, source: 'unitypackage' };
|
|
385
|
+
fs.mkdirSync(out, { recursive: true });
|
|
386
|
+
fs.writeFileSync(path.join(out, 'pack.json'), JSON.stringify(manifest, null, 2));
|
|
387
|
+
sendJson(res, 200, { ok: true, pack, copied, clips, dest: out });
|
|
388
|
+
} catch (e) { sendJson(res, 500, { err: String(e).slice(0, 300) }); }
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
208
394
|
// THE ENGINE DEV GUI (/__sindicate/) — served by the dev server, talks to the RUNNING
|
|
209
395
|
// game through the game bus. v1: live tunables (core/tune.js groups render as sliders;
|
|
210
396
|
// edits apply in the game the same frame). Panels for add-ons/packs mount here next.
|
|
@@ -221,6 +407,86 @@ export function guiPlugin() {
|
|
|
221
407
|
</style></head><body>
|
|
222
408
|
<h1>SINDICATE</h1><div class="sub">dev panel — tunables apply LIVE in the running game</div>
|
|
223
409
|
<div id="groups"></div><div class="status" id="status">connecting to the game bus…</div>
|
|
410
|
+
<div class="grp" style="margin-top:26px"><h2>Packages</h2>
|
|
411
|
+
<div style="font-size:12px;color:#8d8875;margin-bottom:8px">The Sindicate catalog — install into THIS game with one click (or: sindicate install <file> --game .)</div>
|
|
412
|
+
<div id="ad-list" style="font-size:12px;color:#b8b09a"></div>
|
|
413
|
+
<div id="ad-status" style="font-size:12px;color:#8d8875;margin-top:6px"></div>
|
|
414
|
+
</div>
|
|
415
|
+
<script>
|
|
416
|
+
async function adRefresh() {
|
|
417
|
+
try {
|
|
418
|
+
const j = await fetch('/__pack/registry').then((r) => r.json());
|
|
419
|
+
const el = document.getElementById('ad-list'); el.innerHTML = '';
|
|
420
|
+
for (const p of j.packages ?? []) {
|
|
421
|
+
const row = document.createElement('div');
|
|
422
|
+
row.style.cssText = 'display:flex;align-items:flex-start;gap:10px;margin:8px 0';
|
|
423
|
+
const status = p.installed ? '<span style="color:#7dc24a">installed v' + p.installed + '</span>'
|
|
424
|
+
: p.packageFile ? '<span style="color:#e8c66a">available</span>'
|
|
425
|
+
: '<span style="color:#6d6858">' + (p.note ?? 'not packaged') + '</span>';
|
|
426
|
+
row.innerHTML = '<div style="flex:1"><span style="color:#f5ead0">' + p.name + '</span> <span style="color:#6d6858">· ' + p.type + '</span> ' + status
|
|
427
|
+
+ '<br><span style="color:#8d8875">' + (p.description ?? '') + '</span></div>';
|
|
428
|
+
if (!p.installed && p.packageFile) {
|
|
429
|
+
const btn = document.createElement('button');
|
|
430
|
+
btn.textContent = 'Install';
|
|
431
|
+
btn.style.cssText = 'padding:3px 12px;border:1px solid #3a3a52;border-radius:5px;background:#22223a;color:#e8c66a;cursor:pointer;font-size:12px;flex:none';
|
|
432
|
+
btn.onclick = async () => {
|
|
433
|
+
btn.disabled = true;
|
|
434
|
+
document.getElementById('ad-status').textContent = 'installing ' + p.name + '…';
|
|
435
|
+
const r = await fetch('/__pack/installAddon', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ file: p.packageFile }) }).then((x) => x.json());
|
|
436
|
+
document.getElementById('ad-status').textContent = r.ok ? 'installed ' + p.name + ' — restart the dev server to use it' : 'failed: ' + (r.err ?? '?');
|
|
437
|
+
adRefresh();
|
|
438
|
+
};
|
|
439
|
+
row.appendChild(btn);
|
|
440
|
+
}
|
|
441
|
+
el.appendChild(row);
|
|
442
|
+
}
|
|
443
|
+
} catch (e) { document.getElementById('ad-list').textContent = 'unreachable: ' + e.message; }
|
|
444
|
+
}
|
|
445
|
+
adRefresh();
|
|
446
|
+
</script>
|
|
447
|
+
<div class="grp" style="margin-top:14px"><h2>Import Packs</h2>
|
|
448
|
+
<div style="font-size:12px;color:#8d8875;margin-bottom:10px">Drop or choose a .unitypackage — assets land in THIS game's repo (public/assets/packs/). Animations cook through accentimation-converter.</div>
|
|
449
|
+
<input type="file" id="pk-file" accept=".unitypackage" style="color:#b8b09a;font-size:12px">
|
|
450
|
+
<div id="pk-status" style="font-size:12px;color:#8d8875;margin-top:8px"></div>
|
|
451
|
+
<div id="pk-list" style="margin-top:10px"></div>
|
|
452
|
+
</div>
|
|
453
|
+
<script>
|
|
454
|
+
const pkStatus = (m) => { document.getElementById('pk-status').textContent = m; };
|
|
455
|
+
async function pkRefresh() {
|
|
456
|
+
try {
|
|
457
|
+
const j = await fetch('/__pack/list').then((r) => r.json());
|
|
458
|
+
const el = document.getElementById('pk-list'); el.innerHTML = '';
|
|
459
|
+
for (const p of j.packs ?? []) {
|
|
460
|
+
const row = document.createElement('div');
|
|
461
|
+
row.style.cssText = 'display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px;color:#b8b09a';
|
|
462
|
+
const inv = p.inventory;
|
|
463
|
+
const done = (j.imported ?? []).includes(p.pack);
|
|
464
|
+
row.innerHTML = '<span style="color:#f5ead0">' + p.pack + '</span><span>' + inv.models + ' models · ' + inv.textures + ' tex · ' + inv.animations + ' anims</span>' + (done ? '<span style="color:#7dc24a">imported</span>' : '');
|
|
465
|
+
const btn = document.createElement('button');
|
|
466
|
+
btn.textContent = done ? 'Re-import' : 'Import';
|
|
467
|
+
btn.style.cssText = 'padding:3px 12px;border:1px solid #3a3a52;border-radius:5px;background:#22223a;color:#e8c66a;cursor:pointer;font-size:12px';
|
|
468
|
+
btn.onclick = async () => {
|
|
469
|
+
btn.disabled = true; pkStatus('importing ' + p.pack + '…');
|
|
470
|
+
const r = await fetch('/__pack/import', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pack: p.pack }) }).then((x) => x.json());
|
|
471
|
+
pkStatus(r.ok ? 'imported ' + p.pack + ': ' + r.copied.models + ' models, ' + r.copied.textures + ' textures' + (r.clips != null ? ', clips: ' + r.clips : '') : 'failed: ' + (r.err ?? '?'));
|
|
472
|
+
btn.disabled = false; pkRefresh();
|
|
473
|
+
};
|
|
474
|
+
row.appendChild(btn); el.appendChild(row);
|
|
475
|
+
}
|
|
476
|
+
if (!(j.packs ?? []).length) el.innerHTML = '<div style="font-size:12px;color:#6d6858">nothing staged yet</div>';
|
|
477
|
+
} catch (e) { pkStatus('importer unreachable: ' + e.message); }
|
|
478
|
+
}
|
|
479
|
+
document.getElementById('pk-file').onchange = async (ev) => {
|
|
480
|
+
const f = ev.target.files[0]; if (!f) return;
|
|
481
|
+
pkStatus('uploading ' + f.name + ' (' + Math.round(f.size / 1048576) + 'MB)…');
|
|
482
|
+
try {
|
|
483
|
+
const j = await fetch('/__pack/upload?name=' + encodeURIComponent(f.name), { method: 'POST', body: f }).then((r) => r.json());
|
|
484
|
+
pkStatus(j.err ? 'failed: ' + j.err : 'staged ' + j.pack + ' — ' + j.staged + ' files');
|
|
485
|
+
pkRefresh();
|
|
486
|
+
} catch (e) { pkStatus('upload failed: ' + e.message); }
|
|
487
|
+
};
|
|
488
|
+
pkRefresh();
|
|
489
|
+
</script>
|
|
224
490
|
<script>
|
|
225
491
|
const cmd = (js) => fetch('/__game/cmd', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ js }) }).then((r) => r.json());
|
|
226
492
|
const take = async (id) => { for (let i = 0; i < 40; i++) { const r = await fetch('/__game/take?id=' + id).then((x) => x.json()); if (!r.pending) return r; await new Promise((res) => setTimeout(res, 250)); } return { err: 'timeout' }; };
|
|
@@ -273,6 +539,61 @@ export function guiPlugin() {
|
|
|
273
539
|
};
|
|
274
540
|
}
|
|
275
541
|
|
|
542
|
+
// THE WORLD DESIGNER's server half — layout JSON persistence (designs/<name>.json in the
|
|
543
|
+
// GAME repo: machine-written layouts are JSON by law) + the imported-pack model listing
|
|
544
|
+
// the palette feeds on. The designer BUS (an MCP server's way in) is a busPlugin instance
|
|
545
|
+
// registered alongside this in sindicateDevTools.
|
|
546
|
+
export function designerPlugin({ packsDir = 'public/assets/packs', designsDir = 'designs' } = {}) {
|
|
547
|
+
const NAME_RE = /^[\w.-]+$/;
|
|
548
|
+
return {
|
|
549
|
+
name: 'sindicate-designer',
|
|
550
|
+
apply: 'serve',
|
|
551
|
+
configureServer(server) {
|
|
552
|
+
// model + texture inventory of ONE imported pack (palette fodder)
|
|
553
|
+
server.middlewares.use('/__designer/models', (req, res) => {
|
|
554
|
+
try {
|
|
555
|
+
const pack = new URL(req.url, 'http://x').searchParams.get('pack') || '';
|
|
556
|
+
if (!NAME_RE.test(pack)) return sendJson(res, 400, { err: 'bad pack name' });
|
|
557
|
+
const mDir = path.join(packsDir, pack, 'models');
|
|
558
|
+
const tDir = path.join(packsDir, pack, 'textures');
|
|
559
|
+
const models = fs.existsSync(mDir) ? fs.readdirSync(mDir).filter((f) => /\.(fbx|glb)$/i.test(f)) : [];
|
|
560
|
+
const textures = fs.existsSync(tDir) ? fs.readdirSync(tDir).filter((f) => /\.(png|jpg|jpeg|tga)$/i.test(f)) : [];
|
|
561
|
+
// the pack's likeliest shared atlas — Synty convention first, else the first texture
|
|
562
|
+
const atlas = textures.find((t) => /_Texture_01/i.test(t)) ?? textures.find((t) => /texture/i.test(t)) ?? textures[0] ?? null;
|
|
563
|
+
sendJson(res, 200, { models, textures, tex: atlas ? `/assets/packs/${pack}/textures/${atlas}` : null });
|
|
564
|
+
} catch (e) { sendJson(res, 500, { err: String(e) }); }
|
|
565
|
+
});
|
|
566
|
+
server.middlewares.use('/__designer/save', async (req, res) => {
|
|
567
|
+
if (req.method !== 'POST') return sendJson(res, 405, { err: 'POST only' });
|
|
568
|
+
const name = new URL(req.url, 'http://x').searchParams.get('name') || '';
|
|
569
|
+
if (!NAME_RE.test(name)) return sendJson(res, 400, { err: 'bad design name' });
|
|
570
|
+
try {
|
|
571
|
+
const body = JSON.parse((await readBody(req)).toString()); // parse = validate: only real JSON lands on disk
|
|
572
|
+
fs.mkdirSync(designsDir, { recursive: true });
|
|
573
|
+
fs.writeFileSync(path.join(designsDir, `${name}.json`), JSON.stringify(body, null, 2));
|
|
574
|
+
sendJson(res, 200, { ok: true, file: `${designsDir}/${name}.json` });
|
|
575
|
+
} catch (e) { sendJson(res, 500, { err: String(e) }); }
|
|
576
|
+
});
|
|
577
|
+
server.middlewares.use('/__designer/load', (req, res) => {
|
|
578
|
+
const name = new URL(req.url, 'http://x').searchParams.get('name') || '';
|
|
579
|
+
if (!NAME_RE.test(name)) return sendJson(res, 400, { err: 'bad design name' });
|
|
580
|
+
const file = path.join(designsDir, `${name}.json`);
|
|
581
|
+
if (!fs.existsSync(file)) return sendJson(res, 404, { err: 'no such design' });
|
|
582
|
+
try { sendJson(res, 200, JSON.parse(fs.readFileSync(file, 'utf8'))); }
|
|
583
|
+
catch (e) { sendJson(res, 500, { err: String(e) }); }
|
|
584
|
+
});
|
|
585
|
+
server.middlewares.use('/__designer/list', (req, res) => {
|
|
586
|
+
try {
|
|
587
|
+
const designs = fs.existsSync(designsDir)
|
|
588
|
+
? fs.readdirSync(designsDir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5))
|
|
589
|
+
: [];
|
|
590
|
+
sendJson(res, 200, { designs });
|
|
591
|
+
} catch (e) { sendJson(res, 500, { err: String(e) }); }
|
|
592
|
+
});
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
276
597
|
export function sindicateDevTools({
|
|
277
598
|
sourceWrites = [],
|
|
278
599
|
assetLists = [],
|
|
@@ -294,8 +615,11 @@ export function sindicateDevTools({
|
|
|
294
615
|
...stores.map(binStorePlugin),
|
|
295
616
|
perfSinkPlugin(perfSink),
|
|
296
617
|
guiPlugin(),
|
|
618
|
+
packImporterPlugin(),
|
|
297
619
|
busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
|
|
298
620
|
busPlugin({ name: 'editor', prefix: '/__editor' }),
|
|
621
|
+
busPlugin({ name: 'designer', prefix: '/__designer-bus' }),
|
|
622
|
+
designerPlugin(),
|
|
299
623
|
editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),
|
|
300
624
|
];
|
|
301
625
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// FBX → GLB (pack bake) — Synty props arrive as raw .fbx with their unit scale living in
|
|
3
|
+
// Unity's per-file import settings (PolygonCity is metre-authored at globalScale 100,
|
|
4
|
+
// PolygonTown is centimetres at 1). Shipping raw FBX makes every consumer carry both an
|
|
5
|
+
// FBXLoader and the scale table; baking to GLB with the scale applied kills both.
|
|
6
|
+
//
|
|
7
|
+
// node tools/fbxToGlb.mjs <packDir>
|
|
8
|
+
//
|
|
9
|
+
// Reads <packDir>/fbxScale.json (written by truthToAssemblies from the Unity .meta files),
|
|
10
|
+
// converts every listed .fbx, rewrites the assemblies + patches + index to point at the GLBs,
|
|
11
|
+
// and leaves the .fbx sources in place (nothing is deleted — re-runnable).
|
|
12
|
+
//
|
|
13
|
+
// Geometry convention: whatever three's FBXLoader produces, world-baked. That is exactly what
|
|
14
|
+
// the viewers rendered before, so the flipped instance transforms from truthToAssemblies keep
|
|
15
|
+
// matching. No handedness change happens here.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { writeGlb } from './glbWrite.mjs';
|
|
19
|
+
|
|
20
|
+
// three's FBXLoader touches a few DOM APIs (texture loading paths we never hit)
|
|
21
|
+
globalThis.self = globalThis;
|
|
22
|
+
globalThis.window = globalThis;
|
|
23
|
+
const stubEl = () => ({ style: {}, addEventListener() {}, removeEventListener() {}, setAttribute() {}, getContext: () => null });
|
|
24
|
+
globalThis.document = { createElementNS: stubEl, createElement: stubEl };
|
|
25
|
+
|
|
26
|
+
const THREE = await import('three');
|
|
27
|
+
const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
|
|
28
|
+
|
|
29
|
+
const [packDir] = process.argv.slice(2);
|
|
30
|
+
if (!packDir) { console.error('usage: fbxToGlb.mjs <packDir>'); process.exit(1); }
|
|
31
|
+
|
|
32
|
+
const modelsDir = path.join(packDir, 'models');
|
|
33
|
+
const scalePath = path.join(packDir, 'fbxScale.json');
|
|
34
|
+
const scales = fs.existsSync(scalePath) ? JSON.parse(fs.readFileSync(scalePath, 'utf8')) : {};
|
|
35
|
+
const loader = new FBXLoader();
|
|
36
|
+
|
|
37
|
+
function convert(fbxName, scale) {
|
|
38
|
+
const buf = fs.readFileSync(path.join(modelsDir, fbxName));
|
|
39
|
+
const root = loader.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), '');
|
|
40
|
+
root.scale.setScalar(scale);
|
|
41
|
+
root.updateMatrixWorld(true);
|
|
42
|
+
|
|
43
|
+
// world-bake every mesh into one buffer (parts get a single material from the truth data,
|
|
44
|
+
// so submesh splits buy nothing and cost draw calls)
|
|
45
|
+
const pos = [], nrm = [], uv = [], idx = [];
|
|
46
|
+
const v = new THREE.Vector3();
|
|
47
|
+
const nm = new THREE.Matrix3();
|
|
48
|
+
root.traverse((o) => {
|
|
49
|
+
if (!o.isMesh) return;
|
|
50
|
+
const g = o.geometry;
|
|
51
|
+
const p = g.attributes.position;
|
|
52
|
+
if (!p) return;
|
|
53
|
+
const base = pos.length / 3;
|
|
54
|
+
nm.getNormalMatrix(o.matrixWorld);
|
|
55
|
+
for (let i = 0; i < p.count; i++) {
|
|
56
|
+
v.fromBufferAttribute(p, i).applyMatrix4(o.matrixWorld);
|
|
57
|
+
pos.push(v.x, v.y, v.z);
|
|
58
|
+
if (g.attributes.normal) {
|
|
59
|
+
v.fromBufferAttribute(g.attributes.normal, i).applyMatrix3(nm).normalize();
|
|
60
|
+
nrm.push(v.x, v.y, v.z);
|
|
61
|
+
} else nrm.push(0, 1, 0);
|
|
62
|
+
if (g.attributes.uv) uv.push(g.attributes.uv.getX(i), g.attributes.uv.getY(i));
|
|
63
|
+
else uv.push(0, 0);
|
|
64
|
+
}
|
|
65
|
+
if (g.index) for (let i = 0; i < g.index.count; i++) idx.push(g.index.getX(i) + base);
|
|
66
|
+
else for (let i = 0; i < p.count; i++) idx.push(base + i);
|
|
67
|
+
});
|
|
68
|
+
if (!pos.length) throw new Error('no geometry');
|
|
69
|
+
const glbName = fbxName.replace(/\.fbx$/i, '.glb');
|
|
70
|
+
const r = writeGlb({
|
|
71
|
+
name: path.basename(glbName, '.glb'),
|
|
72
|
+
vertexCount: pos.length / 3,
|
|
73
|
+
indices: idx,
|
|
74
|
+
attrs: {
|
|
75
|
+
POSITION: { array: new Float32Array(pos), dim: 3 },
|
|
76
|
+
NORMAL: { array: new Float32Array(nrm), dim: 3 },
|
|
77
|
+
TEXCOORD_0: { array: new Float32Array(uv), dim: 2 },
|
|
78
|
+
},
|
|
79
|
+
subs: [{ firstIndex: 0, indexCount: idx.length, baseVertex: 0 }],
|
|
80
|
+
}, path.join(modelsDir, glbName));
|
|
81
|
+
return { glbName, ...r };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const renamed = {};
|
|
85
|
+
for (const [fbxName, scale] of Object.entries(scales)) {
|
|
86
|
+
if (!fs.existsSync(path.join(modelsDir, fbxName))) { console.warn(`missing ${fbxName}`); continue; }
|
|
87
|
+
try {
|
|
88
|
+
const r = convert(fbxName, scale);
|
|
89
|
+
renamed[fbxName] = r.glbName;
|
|
90
|
+
console.log(`${fbxName} ×${scale} → ${r.glbName} ${r.verts} verts, ${r.tris} tris`);
|
|
91
|
+
} catch (e) { console.warn(`FAILED ${fbxName}: ${e.message}`); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// repoint assemblies, patches and the model index at the GLBs
|
|
95
|
+
let touched = 0;
|
|
96
|
+
for (const dir of ['assemblies', 'patches']) {
|
|
97
|
+
const d = path.join(packDir, dir);
|
|
98
|
+
if (!fs.existsSync(d)) continue;
|
|
99
|
+
for (const f of fs.readdirSync(d)) {
|
|
100
|
+
if (!f.endsWith('.json') || f === 'index.json') continue;
|
|
101
|
+
const p = path.join(d, f);
|
|
102
|
+
const man = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
103
|
+
let hit = false;
|
|
104
|
+
for (const part of man.parts) if (renamed[part.file]) { part.file = renamed[part.file]; hit = true; }
|
|
105
|
+
if (hit) { fs.writeFileSync(p, JSON.stringify(man)); touched++; }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const idxPath = path.join(packDir, 'index.json');
|
|
109
|
+
if (fs.existsSync(idxPath)) {
|
|
110
|
+
const index = JSON.parse(fs.readFileSync(idxPath, 'utf8'))
|
|
111
|
+
.map((f) => renamed[f] ?? f)
|
|
112
|
+
.filter((f) => !/\.fbx$/i.test(f));
|
|
113
|
+
fs.writeFileSync(idxPath, JSON.stringify([...new Set(index)].sort()));
|
|
114
|
+
}
|
|
115
|
+
console.log(`${Object.keys(renamed).length} converted · ${touched} manifests repointed`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// GLB WRITER — shared by unityMeshToGlb (Unity YAML mesh recovery) and
|
|
2
|
+
// rebuildSimpleRoundabouts (synthesized replacements for Kiwi's deleted meshes).
|
|
3
|
+
// mesh = { name, vertexCount, indices, attrs: { POSITION: {array, dim}, ... }, subs }
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export function writeGlb(mesh, outFile) {
|
|
7
|
+
const buffers = [];
|
|
8
|
+
let byteLen = 0;
|
|
9
|
+
const views = [];
|
|
10
|
+
const push = (buf, target) => {
|
|
11
|
+
while (byteLen % 4) { buffers.push(Buffer.alloc(1)); byteLen++; }
|
|
12
|
+
views.push({ buffer: 0, byteOffset: byteLen, byteLength: buf.length, ...(target ? { target } : {}) });
|
|
13
|
+
buffers.push(buf); byteLen += buf.length;
|
|
14
|
+
return views.length - 1;
|
|
15
|
+
};
|
|
16
|
+
const accessors = [];
|
|
17
|
+
const attrAcc = {};
|
|
18
|
+
for (const [sem, a] of Object.entries(mesh.attrs)) {
|
|
19
|
+
const buf = Buffer.from(a.array.buffer, a.array.byteOffset, a.array.byteLength);
|
|
20
|
+
const view = push(buf, 34962);
|
|
21
|
+
let min, max;
|
|
22
|
+
if (sem === 'POSITION') {
|
|
23
|
+
min = [Infinity, Infinity, Infinity]; max = [-Infinity, -Infinity, -Infinity];
|
|
24
|
+
for (let v = 0; v < mesh.vertexCount; v++) for (let d = 0; d < 3; d++) {
|
|
25
|
+
const x = a.array[v * 3 + d];
|
|
26
|
+
if (x < min[d]) min[d] = x;
|
|
27
|
+
if (x > max[d]) max[d] = x;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
accessors.push({ bufferView: view, componentType: 5126, count: mesh.vertexCount, type: ['SCALAR', 'VEC2', 'VEC3', 'VEC4'][a.dim - 1], ...(min ? { min, max } : {}) });
|
|
31
|
+
attrAcc[sem] = accessors.length - 1;
|
|
32
|
+
}
|
|
33
|
+
const primitives = mesh.subs.map((s) => {
|
|
34
|
+
const idx = new Uint32Array(mesh.indices.slice(s.firstIndex, s.firstIndex + s.indexCount).map((i) => i + s.baseVertex));
|
|
35
|
+
const view = push(Buffer.from(idx.buffer), 34963);
|
|
36
|
+
accessors.push({ bufferView: view, componentType: 5125, count: idx.length, type: 'SCALAR' });
|
|
37
|
+
return { attributes: attrAcc, indices: accessors.length - 1, mode: 4 };
|
|
38
|
+
});
|
|
39
|
+
const gltf = {
|
|
40
|
+
asset: { version: '2.0', generator: 'sindicate unityMeshToGlb' },
|
|
41
|
+
scene: 0,
|
|
42
|
+
scenes: [{ nodes: [0] }],
|
|
43
|
+
nodes: [{ mesh: 0, name: mesh.name }],
|
|
44
|
+
meshes: [{ name: mesh.name, primitives }],
|
|
45
|
+
accessors,
|
|
46
|
+
bufferViews: views,
|
|
47
|
+
buffers: [{ byteLength: byteLen }],
|
|
48
|
+
};
|
|
49
|
+
const bin = Buffer.concat(buffers, byteLen);
|
|
50
|
+
let json = Buffer.from(JSON.stringify(gltf));
|
|
51
|
+
while (json.length % 4) json = Buffer.concat([json, Buffer.from(' ')]);
|
|
52
|
+
const binPad = (4 - (bin.length % 4)) % 4;
|
|
53
|
+
const paddedBin = binPad ? Buffer.concat([bin, Buffer.alloc(binPad)]) : bin;
|
|
54
|
+
const total = 12 + 8 + json.length + 8 + paddedBin.length;
|
|
55
|
+
const out = Buffer.alloc(total);
|
|
56
|
+
out.writeUInt32LE(0x46546c67, 0); out.writeUInt32LE(2, 4); out.writeUInt32LE(total, 8);
|
|
57
|
+
out.writeUInt32LE(json.length, 12); out.writeUInt32LE(0x4e4f534a, 16); json.copy(out, 20);
|
|
58
|
+
let o = 20 + json.length;
|
|
59
|
+
out.writeUInt32LE(paddedBin.length, o); out.writeUInt32LE(0x004e4942, o + 4); paddedBin.copy(out, o + 8);
|
|
60
|
+
fs.writeFileSync(outFile, out);
|
|
61
|
+
return { verts: mesh.vertexCount, tris: mesh.indices.length / 3, prims: primitives.length, bytes: total };
|
|
62
|
+
}
|