sindicate 0.13.0 → 0.16.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 +118 -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 +3 -2
- package/src/systems/train.js +16 -6
- package/src/vehicle/car.js +321 -0
- package/src/vehicle/carAudio.js +124 -0
- package/src/world/design.js +72 -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/roadAssert.js +125 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +343 -0
- package/src/world/roadNetwork.js +158 -0
- package/src/world/roadTerrain.js +377 -0
- package/src/world/roadWorld.js +200 -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 +57 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPackageExtract.mjs +56 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// THE WORLD DESIGNER (v1) — the engine's world editor. A game exposes it with a one-line
|
|
2
|
+
// designer.html entry:
|
|
3
|
+
//
|
|
4
|
+
// <script type="module">
|
|
5
|
+
// import { startDesigner } from 'sindicate/tools/designer.js';
|
|
6
|
+
// startDesigner({ heightAt: (x, z) => 0 }); // pass the game's heightAt for real terrain
|
|
7
|
+
// </script>
|
|
8
|
+
//
|
|
9
|
+
// v1: orbit viewport · prefab palette (imported packs via /__pack, plus the game's own
|
|
10
|
+
// asset dirs) · click-to-place on the ground · drag-move · Q/E rotate · Delete removes ·
|
|
11
|
+
// road polyline mode · save/load layout JSON through the dev middleware
|
|
12
|
+
// (designs/<name>.json in the GAME repo — machine-written layouts are JSON by law).
|
|
13
|
+
//
|
|
14
|
+
// The DESIGNER BUS (served by designerPlugin alongside /__pack) lets an MCP server drive
|
|
15
|
+
// every one of these actions programmatically while the human watches the viewport.
|
|
16
|
+
import * as THREE from 'three/webgpu';
|
|
17
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
18
|
+
import { loadModel } from '../src/core/assets.js';
|
|
19
|
+
|
|
20
|
+
const state = {
|
|
21
|
+
layout: { name: 'untitled', prefabs: [], roads: [] },
|
|
22
|
+
placed: [], // { entry, object } parallel to layout.prefabs
|
|
23
|
+
sel: -1,
|
|
24
|
+
mode: 'place', // 'place' | 'road' | 'select'
|
|
25
|
+
activePrefab: null, // { file, dir, tex }
|
|
26
|
+
roadPts: [],
|
|
27
|
+
heightAt: () => 0,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function startDesigner({ heightAt, worldSize = 2000 } = {}) {
|
|
31
|
+
if (heightAt) state.heightAt = heightAt;
|
|
32
|
+
document.body.style.cssText = 'margin:0;overflow:hidden;background:#101018;font:13px -apple-system,sans-serif;color:#e8e2d0';
|
|
33
|
+
|
|
34
|
+
// ── viewport ──
|
|
35
|
+
const renderer = new THREE.WebGPURenderer({ antialias: true });
|
|
36
|
+
await renderer.init();
|
|
37
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
38
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
39
|
+
document.body.appendChild(renderer.domElement);
|
|
40
|
+
const scene = new THREE.Scene();
|
|
41
|
+
scene.background = new THREE.Color(0x181826);
|
|
42
|
+
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, worldSize * 4);
|
|
43
|
+
camera.position.set(0, worldSize * 0.28, worldSize * 0.4); // open on the whole map, not a patch of dirt
|
|
44
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
45
|
+
controls.target.set(0, 0, 0);
|
|
46
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 1.2));
|
|
47
|
+
const sun = new THREE.DirectionalLight(0xfff2dd, 2.2);
|
|
48
|
+
sun.position.set(120, 180, 80);
|
|
49
|
+
scene.add(sun);
|
|
50
|
+
// the game's REAL ground, low-res — placement is judged against what the world will be,
|
|
51
|
+
// not a flat grid. 256² segments of heightAt (~66k calls, both games' heightAt is pure
|
|
52
|
+
// math) shaded by relief: valley umber up to pale bone.
|
|
53
|
+
const TSEG = 256;
|
|
54
|
+
const tGeo = new THREE.PlaneGeometry(worldSize, worldSize, TSEG, TSEG).rotateX(-Math.PI / 2);
|
|
55
|
+
{
|
|
56
|
+
const pos = tGeo.attributes.position;
|
|
57
|
+
const cols = new Float32Array(pos.count * 3);
|
|
58
|
+
let lo = Infinity, hi = -Infinity;
|
|
59
|
+
for (let i = 0; i < pos.count; i++) {
|
|
60
|
+
const h = state.heightAt(pos.getX(i), pos.getZ(i));
|
|
61
|
+
pos.setY(i, h);
|
|
62
|
+
if (h < lo) lo = h;
|
|
63
|
+
if (h > hi) hi = h;
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < pos.count; i++) {
|
|
66
|
+
const t = (pos.getY(i) - lo) / Math.max(1e-6, hi - lo);
|
|
67
|
+
cols[i * 3] = 0.24 + 0.5 * t; cols[i * 3 + 1] = 0.22 + 0.42 * t; cols[i * 3 + 2] = 0.18 + 0.3 * t;
|
|
68
|
+
}
|
|
69
|
+
tGeo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
|
|
70
|
+
tGeo.computeVertexNormals();
|
|
71
|
+
}
|
|
72
|
+
const terrain = new THREE.Mesh(tGeo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 1 }));
|
|
73
|
+
terrain.name = 'designer:terrain';
|
|
74
|
+
scene.add(terrain);
|
|
75
|
+
if (!heightAt) scene.add(new THREE.GridHelper(worldSize, 200, 0x3a3a52, 0x26263a)); // flat fallback still reads as a floor
|
|
76
|
+
const groundPlane = new THREE.Mesh(new THREE.PlaneGeometry(worldSize * 4, worldSize * 4).rotateX(-Math.PI / 2),
|
|
77
|
+
new THREE.MeshBasicMaterial({ visible: false }));
|
|
78
|
+
scene.add(groundPlane);
|
|
79
|
+
|
|
80
|
+
const roadLines = new THREE.Group(); scene.add(roadLines);
|
|
81
|
+
addEventListener('resize', () => {
|
|
82
|
+
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
|
83
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── UI shell ──
|
|
87
|
+
const ui = document.createElement('div');
|
|
88
|
+
ui.style.cssText = 'position:fixed;left:0;top:0;bottom:0;width:280px;overflow-y:auto;background:rgba(16,16,26,0.94);border-right:1px solid #2c2c40;padding:14px;z-index:10';
|
|
89
|
+
ui.innerHTML = `
|
|
90
|
+
<div style="color:#e8c66a;font-weight:700;letter-spacing:2px;margin-bottom:2px">SINDICATE DESIGNER</div>
|
|
91
|
+
<div id="d-status" style="color:#8d8875;font-size:11px;margin-bottom:10px">v1</div>
|
|
92
|
+
<div style="display:flex;gap:6px;margin-bottom:10px">
|
|
93
|
+
<input id="d-name" value="untitled" style="flex:1;background:#1c1c2b;border:1px solid #2c2c40;border-radius:5px;color:#e8e2d0;padding:4px 8px;font-size:12px">
|
|
94
|
+
<button id="d-save" style="${BTN}">Save</button>
|
|
95
|
+
<button id="d-load" style="${BTN}">Load</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div style="display:flex;gap:6px;margin-bottom:12px">
|
|
98
|
+
<button id="d-mode-place" style="${BTN}">Place</button>
|
|
99
|
+
<button id="d-mode-road" style="${BTN}">Road</button>
|
|
100
|
+
<button id="d-mode-select" style="${BTN}">Select</button>
|
|
101
|
+
</div>
|
|
102
|
+
<div style="color:#8d8875;font-size:11px;margin-bottom:8px">click = place/pick · drag = move · Q/E rotate · Backspace delete · road mode: click points, Enter ends road</div>
|
|
103
|
+
<input id="d-filter" placeholder="filter prefabs…" style="width:100%;background:#1c1c2b;border:1px solid #2c2c40;border-radius:5px;color:#e8e2d0;padding:5px 8px;font-size:12px;margin-bottom:8px">
|
|
104
|
+
<div id="d-palette" style="font-size:12px"></div>`;
|
|
105
|
+
document.body.appendChild(ui);
|
|
106
|
+
const status = (m) => { ui.querySelector('#d-status').textContent = m; };
|
|
107
|
+
|
|
108
|
+
// ── palette: imported packs + staged inventories via the dev middleware ──
|
|
109
|
+
let palette = []; // { label, file, dir, tex } dir = URL prefix the model loads from
|
|
110
|
+
async function loadPalette() {
|
|
111
|
+
try {
|
|
112
|
+
const reg = await fetch('/__pack/list').then((r) => r.json());
|
|
113
|
+
for (const p of reg.imported ?? []) {
|
|
114
|
+
const files = await fetch(`/__designer/models?pack=${encodeURIComponent(p)}`).then((r) => r.json()).catch(() => ({ models: [] }));
|
|
115
|
+
for (const f of files.models ?? []) {
|
|
116
|
+
const file = f.replace(/\.(fbx|glb)$/i, '');
|
|
117
|
+
const ext = /\.glb$/i.test(f) ? 'glb' : 'fbx';
|
|
118
|
+
palette.push({ label: `${p}/${file}`, file, ext, dir: `/assets/packs/${p}/models`, ...(files.tex ? { tex: files.tex } : {}) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (e) { console.warn('[designer] pack palette failed', e); }
|
|
122
|
+
renderPalette();
|
|
123
|
+
status(`${palette.length} prefabs`);
|
|
124
|
+
}
|
|
125
|
+
function renderPalette(filter = '') {
|
|
126
|
+
const el = ui.querySelector('#d-palette');
|
|
127
|
+
el.innerHTML = '';
|
|
128
|
+
const f = filter.toLowerCase();
|
|
129
|
+
for (const p of palette.filter((p) => !f || p.label.toLowerCase().includes(f)).slice(0, 400)) {
|
|
130
|
+
const row = document.createElement('div');
|
|
131
|
+
row.textContent = p.label;
|
|
132
|
+
row.style.cssText = 'padding:3px 6px;border-radius:4px;cursor:pointer;color:#b8b09a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' + (state.activePrefab?.label === p.label ? ';background:#2c2c48;color:#e8c66a' : '');
|
|
133
|
+
row.onclick = () => { state.activePrefab = p; state.mode = 'place'; renderPalette(ui.querySelector('#d-filter').value); status('placing: ' + p.label); };
|
|
134
|
+
el.appendChild(row);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
ui.querySelector('#d-filter').oninput = (e) => renderPalette(e.target.value);
|
|
138
|
+
ui.querySelector('#d-mode-place').onclick = () => { state.mode = 'place'; status('mode: place'); };
|
|
139
|
+
ui.querySelector('#d-mode-road').onclick = () => { state.mode = 'road'; state.roadPts = []; status('mode: road — click points, Enter to finish'); };
|
|
140
|
+
ui.querySelector('#d-mode-select').onclick = () => { state.mode = 'select'; status('mode: select'); };
|
|
141
|
+
|
|
142
|
+
// ── placement ──
|
|
143
|
+
const raycaster = new THREE.Raycaster();
|
|
144
|
+
const mouse = new THREE.Vector2();
|
|
145
|
+
const groundHit = (ev) => {
|
|
146
|
+
mouse.set((ev.clientX / innerWidth) * 2 - 1, -(ev.clientY / innerHeight) * 2 + 1);
|
|
147
|
+
raycaster.setFromCamera(mouse, camera);
|
|
148
|
+
const hit = raycaster.intersectObjects([terrain, groundPlane])[0]; // real ground first, flat far-field after
|
|
149
|
+
return hit ? { x: hit.point.x, z: hit.point.z } : null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// every mutation lands in sessionStorage: vite's dep-optimize reload (first FBX load
|
|
153
|
+
// discovers new deps) or a stray F5 must never eat a layout in progress
|
|
154
|
+
const persist = () => { try { sessionStorage.setItem('sindicate-designer', JSON.stringify(state.layout)); } catch { /* full */ } };
|
|
155
|
+
|
|
156
|
+
async function placePrefab(entry) {
|
|
157
|
+
const obj = new THREE.Group();
|
|
158
|
+
obj.name = `dz:${entry.file}`;
|
|
159
|
+
try {
|
|
160
|
+
const bare = entry.file.replace(/\.(fbx|glb)$/i, '');
|
|
161
|
+
const ext = entry.ext ?? (/\.glb$/i.test(entry.file) ? 'glb' : 'fbx');
|
|
162
|
+
const model = await loadModel(`${entry.dir}/${bare}.${ext}`, entry.tex ? { texture: entry.tex } : {});
|
|
163
|
+
obj.add(model.clone ? model.clone() : model);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// placeholder box if the model can't load — the layout entry still lands
|
|
166
|
+
obj.add(new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshStandardMaterial({ color: 0xcc5533 })));
|
|
167
|
+
}
|
|
168
|
+
obj.position.set(entry.x, entry.y ?? state.heightAt(entry.x, entry.z), entry.z);
|
|
169
|
+
obj.rotation.y = entry.ry ?? 0;
|
|
170
|
+
scene.add(obj);
|
|
171
|
+
state.layout.prefabs.push(entry);
|
|
172
|
+
state.placed.push({ entry, object: obj });
|
|
173
|
+
persist();
|
|
174
|
+
return state.placed.length - 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function select(i) {
|
|
178
|
+
if (state.sel >= 0) state.placed[state.sel]?.object.traverse((o) => { if (o.material?.emissive) o.material.emissive?.setHex?.(0x000000); });
|
|
179
|
+
state.sel = i;
|
|
180
|
+
if (i >= 0) status(`selected #${i} ${state.placed[i].entry.file}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function removeSelected() {
|
|
184
|
+
if (state.sel < 0) return;
|
|
185
|
+
const { object } = state.placed[state.sel];
|
|
186
|
+
scene.remove(object);
|
|
187
|
+
state.placed.splice(state.sel, 1);
|
|
188
|
+
state.layout.prefabs.splice(state.sel, 1);
|
|
189
|
+
select(-1);
|
|
190
|
+
persist();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function redrawRoads() {
|
|
194
|
+
roadLines.clear();
|
|
195
|
+
for (const road of state.layout.roads) {
|
|
196
|
+
const pts = road.map((p) => new THREE.Vector3(p.x, state.heightAt(p.x, p.z) + 0.3, p.z));
|
|
197
|
+
roadLines.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0xe8c66a })));
|
|
198
|
+
}
|
|
199
|
+
if (state.roadPts.length > 1) {
|
|
200
|
+
const pts = state.roadPts.map((p) => new THREE.Vector3(p.x, state.heightAt(p.x, p.z) + 0.3, p.z));
|
|
201
|
+
roadLines.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x7dc24a })));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
renderer.domElement.addEventListener('pointerdown', async (ev) => {
|
|
206
|
+
if (ev.button !== 0 || ev.target !== renderer.domElement) return;
|
|
207
|
+
const g = groundHit(ev);
|
|
208
|
+
if (!g) return;
|
|
209
|
+
if (state.mode === 'place' && state.activePrefab) {
|
|
210
|
+
const y = state.heightAt(g.x, g.z);
|
|
211
|
+
await placePrefab({ file: state.activePrefab.file, dir: state.activePrefab.dir, ...(state.activePrefab.ext ? { ext: state.activePrefab.ext } : {}), x: +g.x.toFixed(2), y: +y.toFixed(2), z: +g.z.toFixed(2), ry: 0, ...(state.activePrefab.tex ? { tex: state.activePrefab.tex } : {}) });
|
|
212
|
+
status(`placed ${state.activePrefab.file} (${state.layout.prefabs.length} total)`);
|
|
213
|
+
} else if (state.mode === 'road') {
|
|
214
|
+
state.roadPts.push({ x: +g.x.toFixed(1), z: +g.z.toFixed(1) });
|
|
215
|
+
redrawRoads();
|
|
216
|
+
} else if (state.mode === 'select') {
|
|
217
|
+
raycaster.setFromCamera(mouse, camera);
|
|
218
|
+
const hits = raycaster.intersectObjects(state.placed.map((p) => p.object), true);
|
|
219
|
+
if (hits.length) {
|
|
220
|
+
const root = state.placed.findIndex((p) => { let o = hits[0].object; while (o) { if (o === p.object) return true; o = o.parent; } return false; });
|
|
221
|
+
select(root);
|
|
222
|
+
} else select(-1);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
addEventListener('keydown', (ev) => {
|
|
227
|
+
if (ev.target.tagName === 'INPUT') return;
|
|
228
|
+
if (ev.key === 'Enter' && state.mode === 'road' && state.roadPts.length > 1) {
|
|
229
|
+
state.layout.roads.push(state.roadPts);
|
|
230
|
+
state.roadPts = [];
|
|
231
|
+
redrawRoads();
|
|
232
|
+
persist();
|
|
233
|
+
status(`road saved (${state.layout.roads.length} roads)`);
|
|
234
|
+
}
|
|
235
|
+
if (state.sel >= 0) {
|
|
236
|
+
const p = state.placed[state.sel];
|
|
237
|
+
if (ev.key === 'q' || ev.key === 'Q') { p.entry.ry = +((p.entry.ry + Math.PI / 8) % (Math.PI * 2)).toFixed(3); p.object.rotation.y = p.entry.ry; }
|
|
238
|
+
if (ev.key === 'e' || ev.key === 'E') { p.entry.ry = +((p.entry.ry - Math.PI / 8) % (Math.PI * 2)).toFixed(3); p.object.rotation.y = p.entry.ry; }
|
|
239
|
+
if (ev.key === 'Backspace' || ev.key === 'Delete') removeSelected();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── save / load ──
|
|
244
|
+
async function save(name) {
|
|
245
|
+
state.layout.name = name;
|
|
246
|
+
const r = await fetch(`/__designer/save?name=${encodeURIComponent(name)}`, {
|
|
247
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state.layout, null, 2),
|
|
248
|
+
});
|
|
249
|
+
status(r.ok ? `saved designs/${name}.json (${state.layout.prefabs.length} prefabs, ${state.layout.roads.length} roads)` : 'save failed');
|
|
250
|
+
return r.ok;
|
|
251
|
+
}
|
|
252
|
+
async function load(name) {
|
|
253
|
+
const j = await fetch(`/__designer/load?name=${encodeURIComponent(name)}`).then((r) => r.json());
|
|
254
|
+
if (j.err) { status('load failed: ' + j.err); return false; }
|
|
255
|
+
for (const p of state.placed) scene.remove(p.object);
|
|
256
|
+
state.placed = []; state.layout = j; state.sel = -1;
|
|
257
|
+
ui.querySelector('#d-name').value = j.name ?? name;
|
|
258
|
+
const entries = [...state.layout.prefabs];
|
|
259
|
+
state.layout.prefabs = [];
|
|
260
|
+
for (const e of entries) await placePrefab(e);
|
|
261
|
+
redrawRoads();
|
|
262
|
+
persist();
|
|
263
|
+
status(`loaded ${name} (${state.layout.prefabs.length} prefabs, ${state.layout.roads.length} roads)`);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
ui.querySelector('#d-save').onclick = () => save(ui.querySelector('#d-name').value.trim() || 'untitled');
|
|
267
|
+
ui.querySelector('#d-load').onclick = () => load(ui.querySelector('#d-name').value.trim() || 'untitled');
|
|
268
|
+
|
|
269
|
+
// ── THE DESIGNER BUS — the MCP server drives everything above through this ──
|
|
270
|
+
const api = {
|
|
271
|
+
status: () => ({ mode: state.mode, prefabs: state.layout.prefabs.length, roads: state.layout.roads.length, sel: state.sel, active: state.activePrefab?.label ?? null, name: state.layout.name }),
|
|
272
|
+
palette: (f = '') => palette.filter((p) => !f || p.label.toLowerCase().includes(f.toLowerCase())).slice(0, 200).map((p) => p.label),
|
|
273
|
+
place: async ({ file, dir, x, z, y, ry = 0, tex, ext }) => { const i = await placePrefab({ file, dir, ...(ext ? { ext } : {}), x, y: y ?? state.heightAt(x, z), z, ry, ...(tex ? { tex } : {}) }); return { placed: i, total: state.layout.prefabs.length }; },
|
|
274
|
+
move: ({ i, x, z, y, ry }) => { const p = state.placed[i]; if (!p) return { err: 'no such index' };
|
|
275
|
+
if (x != null) p.entry.x = x; if (z != null) p.entry.z = z;
|
|
276
|
+
p.entry.y = y ?? state.heightAt(p.entry.x, p.entry.z);
|
|
277
|
+
if (ry != null) p.entry.ry = ry;
|
|
278
|
+
p.object.position.set(p.entry.x, p.entry.y, p.entry.z); p.object.rotation.y = p.entry.ry; persist(); return { ok: true }; },
|
|
279
|
+
remove: ({ i }) => { select(i); removeSelected(); return { total: state.layout.prefabs.length }; },
|
|
280
|
+
road: ({ pts }) => { state.layout.roads.push(pts); redrawRoads(); persist(); return { roads: state.layout.roads.length }; },
|
|
281
|
+
camera: ({ x, y, z, tx = 0, ty = 0, tz = 0 }) => { camera.position.set(x, y, z); controls.target.set(tx, ty, tz); controls.update(); return { ok: true }; },
|
|
282
|
+
save: ({ name }) => save(name ?? state.layout.name),
|
|
283
|
+
load: ({ name }) => load(name),
|
|
284
|
+
clear: () => { for (const p of state.placed) scene.remove(p.object); state.placed = []; state.layout = { name: state.layout.name, prefabs: [], roads: [] }; redrawRoads(); persist(); return { ok: true }; },
|
|
285
|
+
};
|
|
286
|
+
(async function pollBus() {
|
|
287
|
+
try {
|
|
288
|
+
const { cmds } = await fetch('/__designer-bus/poll').then((r) => r.json());
|
|
289
|
+
for (const c of cmds) {
|
|
290
|
+
let value, err = null;
|
|
291
|
+
try { value = await api[c.op]?.(c.args ?? {}); if (api[c.op] === undefined) err = `unknown op '${c.op}'`; }
|
|
292
|
+
catch (e) { err = String(e); }
|
|
293
|
+
await fetch('/__designer-bus/result', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: c.id, ok: !err, value, err }) });
|
|
294
|
+
}
|
|
295
|
+
} catch { /* dev server hiccup */ }
|
|
296
|
+
setTimeout(pollBus, 400);
|
|
297
|
+
})();
|
|
298
|
+
|
|
299
|
+
// restore a layout in progress (reload survivor — see persist())
|
|
300
|
+
try {
|
|
301
|
+
const kept = JSON.parse(sessionStorage.getItem('sindicate-designer') ?? 'null');
|
|
302
|
+
if (kept && (kept.prefabs?.length || kept.roads?.length)) {
|
|
303
|
+
state.layout = { name: kept.name ?? 'untitled', prefabs: [], roads: kept.roads ?? [] };
|
|
304
|
+
ui.querySelector('#d-name').value = state.layout.name;
|
|
305
|
+
for (const e of kept.prefabs ?? []) await placePrefab(e);
|
|
306
|
+
redrawRoads();
|
|
307
|
+
status(`restored working layout (${state.layout.prefabs.length} prefabs, ${state.layout.roads.length} roads)`);
|
|
308
|
+
}
|
|
309
|
+
} catch { /* nothing kept */ }
|
|
310
|
+
|
|
311
|
+
await loadPalette();
|
|
312
|
+
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
|
|
313
|
+
status(`ready — ${palette.length} prefabs`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const BTN = 'padding:4px 10px;border:1px solid #3a3a52;border-radius:5px;background:#22223a;color:#e8c66a;cursor:pointer;font-size:12px';
|
package/tools/devPlugin.js
CHANGED
|
@@ -539,6 +539,61 @@ export function guiPlugin() {
|
|
|
539
539
|
};
|
|
540
540
|
}
|
|
541
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
|
+
|
|
542
597
|
export function sindicateDevTools({
|
|
543
598
|
sourceWrites = [],
|
|
544
599
|
assetLists = [],
|
|
@@ -563,6 +618,8 @@ export function sindicateDevTools({
|
|
|
563
618
|
packImporterPlugin(),
|
|
564
619
|
busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
|
|
565
620
|
busPlugin({ name: 'editor', prefix: '/__editor' }),
|
|
621
|
+
busPlugin({ name: 'designer', prefix: '/__designer-bus' }),
|
|
622
|
+
designerPlugin(),
|
|
566
623
|
editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),
|
|
567
624
|
];
|
|
568
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
|
+
}
|