sindicate 0.12.0 → 0.13.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/package.json +9 -2
- 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/geo.js +53 -0
- package/src/world/railFormation.js +185 -0
- package/tools/devPlugin.js +267 -0
- package/tools/registry.json +45 -0
- package/tools/sindicate-cli.mjs +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sindicate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -35,5 +35,12 @@
|
|
|
35
35
|
"three": "^0.184.0",
|
|
36
36
|
"three-mesh-bvh": "^0.9.10"
|
|
37
37
|
},
|
|
38
|
-
"license": "UNLICENSED"
|
|
38
|
+
"license": "UNLICENSED",
|
|
39
|
+
"bin": {
|
|
40
|
+
"sindicate": "tools/sindicate-cli.mjs"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"three": "^0.184.0",
|
|
44
|
+
"three-mesh-bvh": "^0.9.10"
|
|
45
|
+
}
|
|
39
46
|
}
|
package/src/core/assets.js
CHANGED
|
@@ -498,6 +498,21 @@ export async function instantiate(url, opts) {
|
|
|
498
498
|
// CONTRACT: these materials are shared game-wide — never mutate one on an instance;
|
|
499
499
|
// clone first (see player.applyMoralityLook).
|
|
500
500
|
const atlasMatCache = new Map();
|
|
501
|
+
|
|
502
|
+
// NodeMaterial.clone() (three r184 WebGPU) silently DROPS the texture and node slots —
|
|
503
|
+
// a private tint clone (player morality, enemy variants, shatter shards) came back
|
|
504
|
+
// bare white. Clone through THIS and the slots ride along.
|
|
505
|
+
export function cloneMat(m) {
|
|
506
|
+
const c = m.clone();
|
|
507
|
+
for (const k of ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap', 'alphaMap', 'bumpMap']) {
|
|
508
|
+
if (m[k] && !c[k]) c[k] = m[k];
|
|
509
|
+
}
|
|
510
|
+
for (const k of ['colorNode', 'emissiveNode', 'roughnessNode', 'positionNode', 'opacityNode', 'normalNode']) {
|
|
511
|
+
if (m[k] && !c[k]) c[k] = m[k];
|
|
512
|
+
}
|
|
513
|
+
return c;
|
|
514
|
+
}
|
|
515
|
+
|
|
501
516
|
export function applyAtlas(root, tex, { emissive = null } = {}) {
|
|
502
517
|
root.traverse((o) => {
|
|
503
518
|
if (!o.isMesh) return;
|
package/src/systems/missions.js
CHANGED
|
@@ -37,7 +37,7 @@ async function captureThumb(game) {
|
|
|
37
37
|
try {
|
|
38
38
|
const { renderer, scene, camera } = game;
|
|
39
39
|
if (!renderer?.readRenderTargetPixelsAsync || !scene || !camera) return null;
|
|
40
|
-
const w =
|
|
40
|
+
const w = 640, h = 360; // pane-sized: 256px thumbs blown up to the detail pane read as mush
|
|
41
41
|
const rt = (captureThumb._rt ||= new THREE.RenderTarget(w, h));
|
|
42
42
|
renderer.setRenderTarget(rt);
|
|
43
43
|
renderer.render(scene, camera);
|
|
@@ -51,7 +51,7 @@ async function captureThumb(game) {
|
|
|
51
51
|
const ctx = c.getContext('2d'), img = ctx.createImageData(w, h);
|
|
52
52
|
img.data.set(buf); // top-left origin — no flip (per map.js)
|
|
53
53
|
ctx.putImageData(img, 0, 0);
|
|
54
|
-
return c.toDataURL('image/jpeg', 0.
|
|
54
|
+
return c.toDataURL('image/jpeg', 0.7);
|
|
55
55
|
} catch (e) { console.warn('[save] thumbnail failed', e?.message ?? e); return null; }
|
|
56
56
|
}
|
|
57
57
|
|
package/src/systems/shatter.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// bounce + settle on the floor, then fade them away. Pure CPU integration on a tiny pool — no engine,
|
|
4
4
|
// no per-shard lights, nothing resident (the meshes are added on smash and removed when faded).
|
|
5
5
|
import * as THREE from 'three/webgpu';
|
|
6
|
+
import { cloneMat } from '../core/assets.js';
|
|
6
7
|
|
|
7
8
|
const G = 17; // gravity (m/s²-ish, tuned for snappy low-poly debris)
|
|
8
9
|
const REST = 0.34; // floor bounce restitution
|
|
@@ -26,7 +27,7 @@ export class Shatter {
|
|
|
26
27
|
const tmpl = frags[i % frags.length];
|
|
27
28
|
const m = tmpl.clone(true);
|
|
28
29
|
// independent material instances so each shard fades on its own
|
|
29
|
-
m.traverse((o) => { if (o.material) o.material = Array.isArray(o.material) ? o.material.map((x) => x
|
|
30
|
+
m.traverse((o) => { if (o.material) o.material = Array.isArray(o.material) ? o.material.map((x) => cloneMat(x)) : cloneMat(o.material); });
|
|
30
31
|
m.scale.multiplyScalar(scale);
|
|
31
32
|
const pivot = tmpl.userData?.pivot;
|
|
32
33
|
let vel;
|
package/src/ui/dialogue.js
CHANGED
|
@@ -42,6 +42,7 @@ let nodeHook = null;
|
|
|
42
42
|
export function setDialogueOptionHandler(fn) { optionHandler = fn; }
|
|
43
43
|
export function setDialogueNodeHook(fn) { nodeHook = fn; }
|
|
44
44
|
import { padMode, buttonCanvas } from './menuHints.js';
|
|
45
|
+
import { THEME } from './theme.js';
|
|
45
46
|
|
|
46
47
|
// ── the two-shot, in numbers ────────────────────────────────────────────────────────────────────
|
|
47
48
|
// The lens stands in FRONT of the NPC (he has already turned to face the player, so his front faces
|
|
@@ -499,7 +500,7 @@ export class Dialogue {
|
|
|
499
500
|
#dlg .dlg-byekey { display:inline-flex; align-items:center; vertical-align:middle; margin-right:9px; }
|
|
500
501
|
#dlg .dlg-byekey.kbd { width:18px; height:18px; justify-content:center; line-height:17px; font-size:12px;
|
|
501
502
|
font-weight:700; color:#f4e3b0; border:1px solid #8a6a3a; border-radius:4px; background:rgba(40,28,14,0.92); }
|
|
502
|
-
|
|
503
|
+
` + (THEME.dialogueCss || '');
|
|
503
504
|
document.head.appendChild(css);
|
|
504
505
|
}
|
|
505
506
|
}
|
package/src/ui/pauseMenu.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { getQualityName, setQualityName, PRESETS } from '../core/quality.js';
|
|
7
7
|
import { renderHints, refreshHints, padMode } from './menuHints.js';
|
|
8
8
|
import { showSaveLoad } from './saveLoadScreen.js';
|
|
9
|
+
import { THEME } from './theme.js';
|
|
9
10
|
|
|
10
11
|
// PAUSE CONTENT is GAME data (setPauseContent in engineSetup):
|
|
11
12
|
// extraItems(game, {resume,close}) → [{label, action}] — game pages (CHARACTER/JOURNAL…)
|
|
@@ -17,10 +18,10 @@ import { showSaveLoad } from './saveLoadScreen.js';
|
|
|
17
18
|
let PAUSE = { saves: null, extraItems: null, settingsRows: null, onQualityChange: null };
|
|
18
19
|
export function setPauseContent(c = {}) { PAUSE = { ...PAUSE, ...c }; }
|
|
19
20
|
|
|
20
|
-
const GOLD =
|
|
21
|
-
const TXT =
|
|
22
|
-
const MUT =
|
|
23
|
-
const FONT =
|
|
21
|
+
const GOLD = THEME.gold;
|
|
22
|
+
const TXT = THEME.text;
|
|
23
|
+
const MUT = THEME.mut;
|
|
24
|
+
const FONT = THEME.font;
|
|
24
25
|
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
25
26
|
const CORNER_SVG = `<svg viewBox="0 0 44 44" width="30" height="30" fill="none" stroke="${GOLD}" stroke-linecap="round"><path d="M3 24 V3 H24" stroke-width="2"/><path d="M9 19 V9 H19" stroke-width="1" opacity="0.6"/><path d="M3 3 h9 l-9 9 z" fill="${GOLD}" stroke="none"/></svg>`;
|
|
26
27
|
const DIVIDER_SVG = `<svg viewBox="0 0 220 14" width="200" height="12" fill="none" stroke="${GOLD}" stroke-width="1.4" stroke-linecap="round"><line x1="8" y1="7" x2="94" y2="7"/><line x1="126" y1="7" x2="212" y2="7"/><circle cx="8" cy="7" r="1.6" fill="${GOLD}"/><circle cx="212" cy="7" r="1.6" fill="${GOLD}"/><path d="M110 1 l7 6 l-7 6 l-7 -6 z" fill="${GOLD}"/></svg>`;
|
|
@@ -32,11 +33,15 @@ function el(tag, css, text) {
|
|
|
32
33
|
return e;
|
|
33
34
|
}
|
|
34
35
|
function framedPanel(w = 420) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
36
|
+
const ground = THEME.panelCss
|
|
37
|
+
?? `color:${TXT};border:1px solid rgba(232,200,122,0.55);border-radius:3px;`
|
|
38
|
+
+ 'background:linear-gradient(157deg,#241b11 0%,#160f09 58%,#0d0805 100%);box-shadow:0 26px 74px rgba(0,0,0,0.86);';
|
|
39
|
+
const p = el('div', `position:relative;width:${w}px;max-width:90vw;padding:28px 36px 24px;${ground}`);
|
|
40
|
+
if (THEME.filigree) {
|
|
41
|
+
p.appendChild(el('div', 'position:absolute;inset:6px;border:1px solid rgba(232,200,122,0.26);border-radius:2px;pointer-events:none;'));
|
|
42
|
+
const c = (pos, rot) => { const d = el('div', `position:absolute;${pos}width:30px;height:30px;pointer-events:none;transform:rotate(${rot}deg);`); d.innerHTML = CORNER_SVG; p.appendChild(d); };
|
|
43
|
+
c('left:3px;top:3px;', 0); c('right:3px;top:3px;', 90); c('right:3px;bottom:3px;', 180); c('left:3px;bottom:3px;', 270);
|
|
44
|
+
}
|
|
40
45
|
return p;
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -55,8 +60,10 @@ export function showPauseMenu(game, opts = {}) {
|
|
|
55
60
|
const close = () => { if (closed) return; closed = true; cancelAnimationFrame(raf); window.removeEventListener('keydown', onKey); root.remove(); };
|
|
56
61
|
const resume = () => { close(); opts.onResume?.(); };
|
|
57
62
|
|
|
58
|
-
const
|
|
59
|
-
const
|
|
63
|
+
const ACT = THEME.btnActive ?? { bg: GOLD, color: '#1a1207', border: GOLD };
|
|
64
|
+
const OFF = THEME.btnCss ? { bg: '', color: '', border: '' } : { bg: 'rgba(232,200,122,0.06)', color: TXT, border: 'rgba(232,200,122,0.45)' };
|
|
65
|
+
const hl = (b, on) => { b.style.background = on ? ACT.bg : OFF.bg; b.style.color = on ? ACT.color : OFF.color; b.style.borderColor = on ? ACT.border : OFF.border; };
|
|
66
|
+
const BTN = THEME.btnCss ?? `padding:9px 20px;border:1px solid rgba(232,200,122,0.45);border-radius:5px;cursor:pointer;font-family:inherit;letter-spacing:2px;font-size:15px;background:rgba(232,200,122,0.06);color:${TXT};transition:background .12s,color .12s;`;
|
|
60
67
|
const rowWrap = () => el('div', 'display:flex;align-items:center;gap:14px;margin:12px 0;padding:5px 6px;border-radius:5px;');
|
|
61
68
|
const rowLabel = (t) => el('div', `width:110px;text-align:right;color:${MUT};font-size:14px;letter-spacing:1px;text-transform:uppercase;`, t);
|
|
62
69
|
|
package/src/ui/saveLoadScreen.js
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
// opts: { mode:'load'|'save', slots:[…ids], list:()=>saves, onLoad(slot), onSave(slot,name),
|
|
8
8
|
// onDelete(slot), onClose() }. Returns { close }.
|
|
9
9
|
import { renderHints, refreshHints, padMode } from './menuHints.js';
|
|
10
|
+
import { THEME } from './theme.js';
|
|
10
11
|
|
|
11
12
|
const UI = '/assets/ui/menu';
|
|
12
|
-
const GOLD =
|
|
13
|
-
const TXT =
|
|
14
|
-
const MUT =
|
|
15
|
-
const FONT =
|
|
13
|
+
const GOLD = THEME.gold;
|
|
14
|
+
const TXT = THEME.text;
|
|
15
|
+
const MUT = THEME.mut;
|
|
16
|
+
const FONT = THEME.font;
|
|
16
17
|
|
|
17
18
|
function el(tag, css, text) {
|
|
18
19
|
const e = document.createElement(tag);
|
|
@@ -27,7 +28,7 @@ export function showSaveLoad(opts = {}) {
|
|
|
27
28
|
const save = opts.mode === 'save';
|
|
28
29
|
const slotIds = opts.slots || ['1', '2', '3', '4', '5', '6'];
|
|
29
30
|
|
|
30
|
-
const root = el('div', `position:fixed;inset:0;z-index:130;display:flex;flex-direction:column;font-family:${FONT};background
|
|
31
|
+
const root = el('div', `position:fixed;inset:0;z-index:130;display:flex;flex-direction:column;font-family:${FONT};background:${THEME.screenBg};`);
|
|
31
32
|
root.appendChild(el('div', `position:absolute;inset:0;background:#000 url(${UI}/title_bg.jpg) center/cover;opacity:0.08;`));
|
|
32
33
|
root.appendChild(el('div', 'position:absolute;inset:0;background:linear-gradient(180deg,rgba(10,7,5,0.86),rgba(10,7,5,0.96));'));
|
|
33
34
|
const head = el('div', 'position:relative;padding:44px 60px 16px;border-bottom:1px solid rgba(232,200,122,0.22);flex:none;');
|
|
@@ -49,9 +50,12 @@ export function showSaveLoad(opts = {}) {
|
|
|
49
50
|
|
|
50
51
|
const showDetail = (rec) => {
|
|
51
52
|
if (!rec) { detail.innerHTML = `<div style="flex:1;display:flex;align-items:center;justify-content:center;color:${MUT};letter-spacing:2px">${save ? 'NEW SAVE' : 'EMPTY'}</div>`; return; }
|
|
52
|
-
const img = rec.thumb ? `<img src="${rec.thumb}" style="width:100%;height:100%;object-fit:cover">`
|
|
53
|
-
: `<div style="
|
|
54
|
-
|
|
53
|
+
const img = rec.thumb ? `<img src="${rec.thumb}" style="width:100%;height:100%;object-fit:cover;display:block">`
|
|
54
|
+
: `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:${MUT};font-size:12px;letter-spacing:3px">NO PREVIEW</div>`;
|
|
55
|
+
// a FRAMED 16:9 plate, not a full-pane stretch — a small thumb blown to the pane reads as mush
|
|
56
|
+
detail.innerHTML = `<div style="padding:22px 24px 0"><div style="aspect-ratio:16/9;max-height:340px;margin:0 auto;`
|
|
57
|
+
+ `background:linear-gradient(135deg,#1a130b,#0d0906);border:1px solid rgba(232,200,122,0.28);border-radius:3px;`
|
|
58
|
+
+ `box-shadow:0 10px 30px rgba(0,0,0,0.5), inset 0 0 40px rgba(0,0,0,0.35);overflow:hidden">${img}</div></div>`
|
|
55
59
|
+ `<div style="padding:20px 24px"><div style="color:${GOLD};font-size:22px;letter-spacing:2px;font-weight:700">${rec.name || rec.title || opts.defaultTitle || 'Saved game'}</div>`
|
|
56
60
|
+ `<div style="color:${MUT};font-size:13px;margin-top:8px;line-height:1.9">${rec.auto ? 'Autosave · ' : ''}${fmtDate(rec.savedAt)}<br>Played ${fmtTime(rec.playtime)} · ${opts.moneyLabel ?? 'Purse $'}${rec.money ?? 0}${rec.objective ? '<br>' + rec.objective : ''}</div></div>`;
|
|
57
61
|
};
|
|
@@ -59,7 +63,7 @@ export function showSaveLoad(opts = {}) {
|
|
|
59
63
|
const rebuild = () => {
|
|
60
64
|
const saves = opts.list ? opts.list() : [];
|
|
61
65
|
const bySlot = {}; saves.forEach((s) => { bySlot[String(s.slot)] = s; });
|
|
62
|
-
const ids = save ? slotIds : ['auto', ...slotIds].filter((id) => bySlot[id]); // load: only filled; save: all manual slots
|
|
66
|
+
const ids = save ? slotIds : [...new Set(['auto', ...slotIds])].filter((id) => bySlot[id]); // load: only filled (auto deduped — a game may list it in its own slots); save: all manual slots
|
|
63
67
|
listEl.innerHTML = ''; items = [];
|
|
64
68
|
if (!ids.length) { listEl.appendChild(el('div', `color:${MUT};opacity:0.8;padding:12px;`, 'No saved games yet.')); showDetail(null); return; }
|
|
65
69
|
ids.forEach((id) => {
|
package/src/ui/theme.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// THE UI THEME — the rule, codified after the fable title-menu incident: the ENGINE owns
|
|
2
|
+
// the FUNCTIONALITY of a screen; the GAME owns its LOOK. Every value here is declarative
|
|
3
|
+
// (a game ships them as src/game/uiTheme.json and registers it in engineSetup with
|
|
4
|
+
// setUiTheme(theme)), so a re-dress never touches engine code.
|
|
5
|
+
//
|
|
6
|
+
// The defaults are the neutral dark leather-and-gold dress the engine screens shipped with
|
|
7
|
+
// (Dustwater's palette — western's own uiTheme.json restates it explicitly). The engine UI
|
|
8
|
+
// modules are lazily imported AFTER engineSetup runs, so module-load reads of THEME see the
|
|
9
|
+
// game's registration.
|
|
10
|
+
//
|
|
11
|
+
// font — the screens' font stack
|
|
12
|
+
// gold/text/mut — accent, body text, muted labels
|
|
13
|
+
// panelCss — the framed panel's ground (popup confirms, the pause panel). Replaces the
|
|
14
|
+
// default gradient+border block; the engine still positions and sizes it.
|
|
15
|
+
// filigree — draw the gold corner flourishes + inner rule on panels (default true; a
|
|
16
|
+
// parchment skin usually wants them off)
|
|
17
|
+
// btnCss — the standard button plate (settings rows, confirms, save-slot actions)
|
|
18
|
+
// btnActive — { bg, color, border } for the highlighted/selected state
|
|
19
|
+
// screenBg — full-screen browser background (save/load screen ground)
|
|
20
|
+
// dialogueCss — appended after the dialogue panel's own CSS; override any #dlg selector
|
|
21
|
+
export const THEME = {
|
|
22
|
+
font: "Copperplate,'Copperplate Gothic Bold','Palatino','Book Antiqua',Georgia,serif",
|
|
23
|
+
gold: '#e8c87a',
|
|
24
|
+
text: '#ecdfc2',
|
|
25
|
+
mut: '#b6a577',
|
|
26
|
+
panelCss: null, // null = the engine's dark leather gradient
|
|
27
|
+
filigree: true,
|
|
28
|
+
btnCss: null, // null = the engine's gold-outline plate
|
|
29
|
+
btnActive: null, // null = { bg: gold, color: '#1a1207', border: gold }
|
|
30
|
+
screenBg: '#0a0705',
|
|
31
|
+
dialogueCss: '',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function setUiTheme(t = {}) { Object.assign(THEME, t); }
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// THE BRIDGE SPLICER (world splits, step 3) — every road x river crossing becomes a deck.
|
|
2
|
+
// Moved verbatim from the games (their copies were byte-identical except two spacing
|
|
3
|
+
// numbers, parameterized below): find crossings, drop hits an existing deck already
|
|
4
|
+
// carries, dedupe widest-first, then splice the road STRAIGHT across the channel on a
|
|
5
|
+
// perpendicular chord — clear-bank anchors, collinear approach legs, rejoin points walked
|
|
6
|
+
// along the old road, chord mids at chordStep, long legs re-densified under legGate. Runs
|
|
7
|
+
// to a FIXED POINT (a splice can create a new crossing), then pushes any surviving road
|
|
8
|
+
// vertex out of the water to the near bank.
|
|
9
|
+
//
|
|
10
|
+
// MUTATES `roads` IN PLACE — call it before anything snapshots them (chunk indices, road
|
|
11
|
+
// benches, registrations: the same import-order law both counties already live by).
|
|
12
|
+
// Returns the BRIDGE_SPOTS array: { ax, az, bx, bz, x, z, w } chord anchors + centre +
|
|
13
|
+
// local channel half-width; the games' bridge builders and deck holds read it from there.
|
|
14
|
+
//
|
|
15
|
+
// chordStep/legGate: western runs 28/30 (the travellers' ≤30 m route-leg law — see its
|
|
16
|
+
// verify-travellers note); fable runs 36/40 (its minimap's 74 m window). Inserted points
|
|
17
|
+
// are collinear on the chord, so roadDistance, the paint and the terrain are bit-identical
|
|
18
|
+
// under either spacing — only where the walkers get their waypoints changes.
|
|
19
|
+
// Headless by law: no imports at all.
|
|
20
|
+
|
|
21
|
+
function segInt(a, b, c, d) { // 2D segment intersection → {x,z,t (road param),u (river param)}
|
|
22
|
+
const rx = b.x - a.x, rz = b.z - a.z, sx = d.x - c.x, sz = d.z - c.z;
|
|
23
|
+
const denom = rx * sz - rz * sx;
|
|
24
|
+
if (Math.abs(denom) < 1e-9) return null;
|
|
25
|
+
const t = ((c.x - a.x) * sz - (c.z - a.z) * sx) / denom;
|
|
26
|
+
const u = ((c.x - a.x) * rz - (c.z - a.z) * rx) / denom;
|
|
27
|
+
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
|
|
28
|
+
return { x: a.x + rx * t, z: a.z + rz * t, t, u };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walkRoad(road, i, t, dist) { // walk `dist` m along the polyline → {x,z,seg,clamped}
|
|
32
|
+
let seg = i, tt = t, remaining = Math.abs(dist), clamped = false;
|
|
33
|
+
const dir = Math.sign(dist) || 1;
|
|
34
|
+
while (remaining > 0) {
|
|
35
|
+
const a = road[seg], b = road[seg + 1];
|
|
36
|
+
if (!a || !b) break;
|
|
37
|
+
const len = Math.hypot(b.x - a.x, b.z - a.z) || 1e-6;
|
|
38
|
+
const along = dir > 0 ? (1 - tt) * len : tt * len;
|
|
39
|
+
if (along >= remaining) { tt += dir * (remaining / len); break; }
|
|
40
|
+
remaining -= along;
|
|
41
|
+
seg += dir; tt = dir > 0 ? 0 : 1;
|
|
42
|
+
if (seg < 0 || seg >= road.length - 1) { seg = Math.max(0, Math.min(road.length - 2, seg)); tt = dir > 0 ? 1 : 0; clamped = true; break; }
|
|
43
|
+
}
|
|
44
|
+
const a = road[seg], b = road[seg + 1];
|
|
45
|
+
return { x: a.x + (b.x - a.x) * tt, z: a.z + (b.z - a.z) * tt, seg, clamped };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function spliceBridges(roads, rivers, { chordStep = 28, legGate = 30, approach = 14, margin = 5, pushClear = 6 } = {}) {
|
|
49
|
+
// metres of dry margin OUTSIDE the nearest river channel (negative = inside the water).
|
|
50
|
+
const riverClearance = (x, z) => {
|
|
51
|
+
let best = Infinity;
|
|
52
|
+
for (const R of rivers) {
|
|
53
|
+
for (let i = 0; i < R.pts.length - 1; i++) {
|
|
54
|
+
const a = R.pts[i], b = R.pts[i + 1];
|
|
55
|
+
const dx = b.x - a.x, dz = b.z - a.z;
|
|
56
|
+
const len2 = dx * dx + dz * dz || 1e-9;
|
|
57
|
+
let t = ((x - a.x) * dx + (z - a.z) * dz) / len2;
|
|
58
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
59
|
+
const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
|
|
60
|
+
const ww = R.ww[i] + (R.ww[Math.min(i + 1, R.ww.length - 1)] - R.ww[i]) * t;
|
|
61
|
+
best = Math.min(best, d - ww);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return best;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const BRIDGE_SPOTS = [];
|
|
68
|
+
const deckVerts = new Set();
|
|
69
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
70
|
+
const hits = [];
|
|
71
|
+
roads.forEach((road, ri) => {
|
|
72
|
+
for (let i = 0; i < road.length - 1; i++)
|
|
73
|
+
for (const R of rivers)
|
|
74
|
+
for (let j = 0; j < R.pts.length - 1; j++) {
|
|
75
|
+
const h = segInt(road[i], road[i + 1], R.pts[j], R.pts[j + 1]);
|
|
76
|
+
if (h) hits.push({ ...h, ri, i, w: R.ww[j] + (R.ww[Math.min(j + 1, R.ww.length - 1)] - R.ww[j]) * h.u });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const carried = (h) => BRIDGE_SPOTS.some((s) => {
|
|
80
|
+
const dx = s.bx - s.ax, dz = s.bz - s.az, l2 = dx * dx + dz * dz || 1e-9;
|
|
81
|
+
let t = ((h.x - s.ax) * dx + (h.z - s.az) * dz) / l2; t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
82
|
+
return Math.hypot(h.x - (s.ax + dx * t), h.z - (s.az + dz * t)) < 8;
|
|
83
|
+
});
|
|
84
|
+
hits.sort((a, b) => b.w - a.w);
|
|
85
|
+
const keep = [];
|
|
86
|
+
for (const h of hits) if (!carried(h) && !keep.some((k) => Math.hypot(k.x - h.x, k.z - h.z) < Math.max(24, k.w * 1.6))) keep.push(h);
|
|
87
|
+
if (!keep.length) break; // fixed point — every crossing is bridged
|
|
88
|
+
keep.sort((a, b) => (b.ri - a.ri) || (b.i - a.i));
|
|
89
|
+
const splicedFrom = new Map();
|
|
90
|
+
for (const h of keep) {
|
|
91
|
+
const road = roads[h.ri];
|
|
92
|
+
const bound = splicedFrom.get(h.ri) ?? road.length;
|
|
93
|
+
if (h.i + 1 >= bound) continue;
|
|
94
|
+
let rv = { x: 1, z: 0 }, bd = Infinity;
|
|
95
|
+
for (const R of rivers) for (let i = 0; i < R.pts.length - 1; i++) {
|
|
96
|
+
const a = R.pts[i], b = R.pts[i + 1];
|
|
97
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
98
|
+
let t = ((h.x - a.x) * dx + (h.z - a.z) * dz) / len2; t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
99
|
+
const d = Math.hypot(h.x - (a.x + dx * t), h.z - (a.z + dz * t));
|
|
100
|
+
if (d < bd) { bd = d; const l = Math.hypot(dx, dz) || 1e-9; rv = { x: dx / l, z: dz / l }; }
|
|
101
|
+
}
|
|
102
|
+
let px = -rv.z, pz = rv.x;
|
|
103
|
+
const rd = { x: road[h.i + 1].x - road[h.i].x, z: road[h.i + 1].z - road[h.i].z };
|
|
104
|
+
if (px * rd.x + pz * rd.z < 0) { px = -px; pz = -pz; }
|
|
105
|
+
const clearOut = (sgn) => {
|
|
106
|
+
const cap = h.w + (h.w > 7 ? 80 : 24);
|
|
107
|
+
let d = h.w + margin;
|
|
108
|
+
while (d < cap && riverClearance(h.x + sgn * px * d, h.z + sgn * pz * d) < margin) d += 2;
|
|
109
|
+
return d < cap ? d : h.w + margin;
|
|
110
|
+
};
|
|
111
|
+
const dA = clearOut(-1), dB = clearOut(1);
|
|
112
|
+
const iA = { x: h.x - px * dA, z: h.z - pz * dA }, iB = { x: h.x + px * dB, z: h.z + pz * dB };
|
|
113
|
+
const aA = { x: iA.x - px * approach, z: iA.z - pz * approach };
|
|
114
|
+
const aB = { x: iB.x + px * approach, z: iB.z + pz * approach };
|
|
115
|
+
const rejoin = (sgn, ref) => {
|
|
116
|
+
const base = (sgn < 0 ? dA : dB) + approach + 12;
|
|
117
|
+
let P = null;
|
|
118
|
+
for (let d = base; d <= base + 60; d += 4) {
|
|
119
|
+
P = walkRoad(road, h.i, h.t, sgn * d);
|
|
120
|
+
if (P.clamped || ((P.x - ref.x) * px + (P.z - ref.z) * pz) * sgn >= 4) return P;
|
|
121
|
+
}
|
|
122
|
+
return P;
|
|
123
|
+
};
|
|
124
|
+
const A = rejoin(-1, aA), B = rejoin(1, aB);
|
|
125
|
+
if (A.clamped && B.clamped) continue;
|
|
126
|
+
let to = B.clamped ? road.length - 1 : B.seg;
|
|
127
|
+
const from = A.clamped ? 0 : A.seg + 1;
|
|
128
|
+
const joinB = to >= bound;
|
|
129
|
+
if (joinB) to = bound - 1;
|
|
130
|
+
if (to < from - 1) continue;
|
|
131
|
+
const start = A.clamped ? iA : aA, end = B.clamped ? iB : aB;
|
|
132
|
+
const ins = [];
|
|
133
|
+
if (!A.clamped && Math.hypot(A.x - start.x, A.z - start.z) > 0.01) ins.push({ x: A.x, z: A.z });
|
|
134
|
+
ins.push(start);
|
|
135
|
+
const runL = Math.hypot(end.x - start.x, end.z - start.z);
|
|
136
|
+
for (let d = chordStep; d < runL - 4; d += chordStep) {
|
|
137
|
+
const mid = { x: start.x + px * d, z: start.z + pz * d };
|
|
138
|
+
ins.push(mid); deckVerts.add(mid);
|
|
139
|
+
}
|
|
140
|
+
ins.push(end);
|
|
141
|
+
if (!B.clamped && !joinB && Math.hypot(B.x - end.x, B.z - end.z) > 0.01) ins.push({ x: B.x, z: B.z });
|
|
142
|
+
for (let k = ins.length - 1; k > 0; k--) {
|
|
143
|
+
const p = ins[k - 1], q = ins[k];
|
|
144
|
+
const L = Math.hypot(q.x - p.x, q.z - p.z);
|
|
145
|
+
if (L > legGate) {
|
|
146
|
+
const n = Math.ceil(L / chordStep), legMids = [];
|
|
147
|
+
for (let s = 1; s < n; s++) legMids.push({ x: p.x + (q.x - p.x) * s / n, z: p.z + (q.z - p.z) * s / n });
|
|
148
|
+
ins.splice(k, 0, ...legMids);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
road.splice(from, to - from + 1, ...ins);
|
|
152
|
+
splicedFrom.set(h.ri, from);
|
|
153
|
+
deckVerts.add(start); deckVerts.add(end);
|
|
154
|
+
BRIDGE_SPOTS.push({ ax: iA.x, az: iA.z, bx: iB.x, bz: iB.z, x: (iA.x + iB.x) / 2, z: (iA.z + iB.z) / 2, w: h.w });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Final pass: any road vertex still INSIDE a channel is pushed to the near bank.
|
|
158
|
+
for (const road of roads) for (const v of road) {
|
|
159
|
+
if (deckVerts.has(v)) continue;
|
|
160
|
+
let best = Infinity, nx = 0, nz = 0;
|
|
161
|
+
for (const R of rivers) for (let i = 0; i < R.pts.length - 1; i++) {
|
|
162
|
+
const a = R.pts[i], b = R.pts[i + 1];
|
|
163
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
164
|
+
let t = ((v.x - a.x) * dx + (v.z - a.z) * dz) / len2; t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
165
|
+
const cxp = a.x + dx * t, czp = a.z + dz * t;
|
|
166
|
+
const d = Math.hypot(v.x - cxp, v.z - czp);
|
|
167
|
+
const ww = R.ww[i] + (R.ww[Math.min(i + 1, R.ww.length - 1)] - R.ww[i]) * t;
|
|
168
|
+
if (d - ww < best) { best = d - ww; nx = cxp; nz = czp; }
|
|
169
|
+
}
|
|
170
|
+
if (best >= pushClear) continue;
|
|
171
|
+
const d = Math.hypot(v.x - nx, v.z - nz) || 1e-6;
|
|
172
|
+
const k = (pushClear - best) / d;
|
|
173
|
+
v.x += (v.x - nx) * k; v.z += (v.z - nz) * k;
|
|
174
|
+
}
|
|
175
|
+
return BRIDGE_SPOTS;
|
|
176
|
+
}
|
package/src/world/geo.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// THE GEO TOOLKIT (world splits, step 1) — the terrain-math primitives both games carried
|
|
2
|
+
// as byte-identical private copies: the fbm/ridged noise kit (fixed ImprovedNoise
|
|
3
|
+
// permutation → deterministic, one shared instance is safe), the smoothstep/terrace
|
|
4
|
+
// shapers, and the point-to-segment distance the polyline indices are built on.
|
|
5
|
+
//
|
|
6
|
+
// HEADLESS BY LAW: this module imports ONLY three/addons math — never three/webgpu —
|
|
7
|
+
// because the games' terrainData files (its consumers) run in Node for offline solvers,
|
|
8
|
+
// verify scripts and height-equivalence probes. Keep it that way.
|
|
9
|
+
//
|
|
10
|
+
// EQUIVALENCE CONTRACT: these bodies moved verbatim from western/fable terrainData.js.
|
|
11
|
+
// Any numeric change here changes both counties' ground — prove a refactor with the
|
|
12
|
+
// height-grid probe (441 samples per game) before shipping it.
|
|
13
|
+
import { ImprovedNoise } from 'three/addons/math/ImprovedNoise.js';
|
|
14
|
+
|
|
15
|
+
const noise = new ImprovedNoise();
|
|
16
|
+
|
|
17
|
+
export function fbm(x, z, octaves = 4, lacunarity = 2, gain = 0.5, scale = 0.01) {
|
|
18
|
+
let amp = 1, freq = scale, sum = 0, norm = 0;
|
|
19
|
+
for (let i = 0; i < octaves; i++) {
|
|
20
|
+
sum += amp * noise.noise(x * freq, z * freq, 7.31 + i * 13.7);
|
|
21
|
+
norm += amp; amp *= gain; freq *= lacunarity;
|
|
22
|
+
}
|
|
23
|
+
return sum / norm;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
27
|
+
export const lerp = (a, b, t) => a + (b - a) * t;
|
|
28
|
+
export function smoothstep(a, b, t) { const x = clamp((t - a) / (b - a), 0, 1); return x * x * (3 - 2 * x); }
|
|
29
|
+
|
|
30
|
+
// ~[0,1]: sharp crests, round valleys — mountain ranges and mesa fields
|
|
31
|
+
export function ridged(x, z, octaves, lac, gain, scale) {
|
|
32
|
+
let amp = 0.5, freq = scale, sum = 0, norm = 0, prev = 1;
|
|
33
|
+
for (let i = 0; i < octaves; i++) {
|
|
34
|
+
let n = 1 - Math.abs(noise.noise(x * freq, z * freq, 41.7 + i * 7.3));
|
|
35
|
+
n *= n; n *= prev; sum += n * amp; norm += amp; prev = n; amp *= gain; freq *= lac;
|
|
36
|
+
}
|
|
37
|
+
return sum / norm;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// quantise to benches with a sharp riser — mesa walls, cliff bands
|
|
41
|
+
export function terrace(h, step, sharp) {
|
|
42
|
+
const f = h / step, k = Math.floor(f), frac = f - k;
|
|
43
|
+
return (k + lerp(frac, smoothstep(0.5 - 0.5 * sharp, 0.5 + 0.5 * sharp, frac), sharp)) * step;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// distance from (px,pz) to segment a→b — the primitive under every polyline index
|
|
47
|
+
export function segDist(px, pz, ax, az, bx, bz) {
|
|
48
|
+
const dx = bx - ax, dz = bz - az;
|
|
49
|
+
const len2 = dx * dx + dz * dz;
|
|
50
|
+
let t = len2 ? ((px - ax) * dx + (pz - az) * dz) / len2 : 0;
|
|
51
|
+
t = clamp(t, 0, 1);
|
|
52
|
+
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t));
|
|
53
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// THE RAIL FORMATION SOLVER (world splits, step 2) — the embankment IS the terrain.
|
|
2
|
+
// Moved verbatim from western's terrainData: the navvy's algorithm that turns a centreline
|
|
3
|
+
// over raw ground into a buildable formation, and the corridor index heightAt interrogates
|
|
4
|
+
// 1.74M times per cold cook. Everything here is PURE MACHINERY — the county supplies the
|
|
5
|
+
// nodes, the surveyed ground, the ruling grade and the weave (its applyRail composition
|
|
6
|
+
// stays game-side, where the river guard and road bench live).
|
|
7
|
+
//
|
|
8
|
+
// HOW A LINE IS SOLVED, at every node of the centreline (~1 m apart):
|
|
9
|
+
// 1. the game SURVEYS the ground into g[] (its own choice of surface — pads in, benches out)
|
|
10
|
+
// 2. LEVEL THE YARDS — each contiguous `flat` run pulled to its own mean
|
|
11
|
+
// 3. THE RULING GRADE — ceil/floor Lipschitz envelopes at maxGrade; their midpoint splits
|
|
12
|
+
// every obstacle evenly between a cutting and an embankment
|
|
13
|
+
// 4. VERTICAL CURVES — a diffusion pass is a curve and cannot break the ruling grade
|
|
14
|
+
// 5. optional WELD onto an already-solved line (one bed through the turnout, no seam)
|
|
15
|
+
// 6. e[] — earthworks weight: 1 = full bench, 0 = past fillMax, flying on a trestle
|
|
16
|
+
//
|
|
17
|
+
// HEADLESS BY LAW, like geo.js: no three imports at all.
|
|
18
|
+
import { clamp, lerp, smoothstep } from './geo.js';
|
|
19
|
+
|
|
20
|
+
// Nearest point on a solved line: how far, and how high the formation is there.
|
|
21
|
+
export function nearestOnLine(nodes, Y, x, z) {
|
|
22
|
+
let bd = Infinity, by = 0;
|
|
23
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
24
|
+
const a = nodes[i], b = nodes[i + 1];
|
|
25
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
26
|
+
const t = clamp(((x - a.x) * dx + (z - a.z) * dz) / len2, 0, 1);
|
|
27
|
+
const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
|
|
28
|
+
if (d < bd) { bd = d; by = lerp(Y[i], Y[i + 1], t); }
|
|
29
|
+
}
|
|
30
|
+
return { d: bd, y: by };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// nodes: [{x, z, s, w, flat}] — s = arc length, w = bed half-width, flat = yard node.
|
|
34
|
+
// g: Float64Array of surveyed ground per node (the game samples its own surface).
|
|
35
|
+
// mergeWith: { nodes, y } of an already-solved line to weld through the shared bed.
|
|
36
|
+
// Returns { y, g, e }.
|
|
37
|
+
export function solveFormation(nodes, g, { maxGrade, smooth = 90, mergeIn = 7, mergeOut = 32, fillMax, mergeWith = null }) {
|
|
38
|
+
const n = nodes.length;
|
|
39
|
+
g = Float64Array.from(g);
|
|
40
|
+
|
|
41
|
+
// ONE BED THROUGH THE TURNOUT: where this line is still in the other's bed, the ground
|
|
42
|
+
// it is surveyed on IS the other line's formation.
|
|
43
|
+
if (mergeWith) {
|
|
44
|
+
for (let i = 0; i < n; i++) {
|
|
45
|
+
const m = nearestOnLine(mergeWith.nodes, mergeWith.y, nodes[i].x, nodes[i].z);
|
|
46
|
+
g[i] = lerp(m.y, g[i], smoothstep(mergeIn, mergeOut, m.d));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// LEVEL THE YARDS — each contiguous run of `flat` nodes pulled to its own mean.
|
|
51
|
+
for (let i = 0; i < n; i++) {
|
|
52
|
+
if (!nodes[i].flat) continue;
|
|
53
|
+
let j = i; while (j < n && nodes[j].flat) j++;
|
|
54
|
+
let sum = 0; for (let k = i; k < j; k++) sum += g[k];
|
|
55
|
+
const mean = sum / (j - i);
|
|
56
|
+
for (let k = i; k < j; k++) g[k] = mean;
|
|
57
|
+
i = j;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// THE RULING GRADE — the two Lipschitz envelopes, and their midpoint.
|
|
61
|
+
const ceil = Float64Array.from(g), floor = Float64Array.from(g);
|
|
62
|
+
for (let i = 1; i < n; i++) {
|
|
63
|
+
const d = maxGrade * (nodes[i].s - nodes[i - 1].s);
|
|
64
|
+
ceil[i] = Math.max(ceil[i], ceil[i - 1] - d); // may only FALL at the ruling grade
|
|
65
|
+
floor[i] = Math.min(floor[i], floor[i - 1] + d); // may only RISE at it
|
|
66
|
+
}
|
|
67
|
+
for (let i = n - 2; i >= 0; i--) {
|
|
68
|
+
const d = maxGrade * (nodes[i + 1].s - nodes[i].s);
|
|
69
|
+
ceil[i] = Math.max(ceil[i], ceil[i + 1] - d);
|
|
70
|
+
floor[i] = Math.min(floor[i], floor[i + 1] + d);
|
|
71
|
+
}
|
|
72
|
+
const y = new Float64Array(n);
|
|
73
|
+
for (let i = 0; i < n; i++) y[i] = 0.5 * (ceil[i] + floor[i]);
|
|
74
|
+
|
|
75
|
+
// THE VERTICAL CURVES.
|
|
76
|
+
const tmp = new Float64Array(n);
|
|
77
|
+
for (let p = 0; p < smooth; p++) {
|
|
78
|
+
tmp.set(y);
|
|
79
|
+
for (let i = 1; i < n - 1; i++) y[i] = 0.25 * tmp[i - 1] + 0.5 * tmp[i] + 0.25 * tmp[i + 1];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// …and weld the result back onto the line it left — bit-identical through the shared bed.
|
|
83
|
+
if (mergeWith) {
|
|
84
|
+
for (let i = 0; i < n; i++) {
|
|
85
|
+
const m = nearestOnLine(mergeWith.nodes, mergeWith.y, nodes[i].x, nodes[i].z);
|
|
86
|
+
y[i] = lerp(m.y, y[i], smoothstep(mergeIn, mergeOut, m.d));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// HOW MUCH EARTH TO MOVE. Past fillMax the bank would be a wall — stop building and fly.
|
|
91
|
+
const e = new Float64Array(n);
|
|
92
|
+
for (let i = 0; i < n; i++) e[i] = 1 - smoothstep(fillMax, fillMax + 1.5, y[i] - g[i]);
|
|
93
|
+
return { y, g, e };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// THE CORRIDOR INDEX — chunked AABBs per line plus ONE box round the whole railway, because
|
|
97
|
+
// the county is huge, the railway is a ribbon, and the common answer must cost four compares.
|
|
98
|
+
// lines: { name: { nodes, y, e } }. Returns { railInfo, railDistance, records } — records is
|
|
99
|
+
// the fixed scratch array (one record per line, rewritten in place, zero allocation per call)
|
|
100
|
+
// the game's own corridor blend iterates.
|
|
101
|
+
export function makeRailIndex(lines, { near, chunk = 32 } = {}) {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
const box = { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity };
|
|
104
|
+
const rl = {};
|
|
105
|
+
for (const name of Object.keys(lines)) {
|
|
106
|
+
rl[name] = { d: Infinity, y: 0, w: 0, e: 1 };
|
|
107
|
+
const { nodes: N, y: Y, e: E } = lines[name];
|
|
108
|
+
for (let i = 0; i < N.length - 1; i += chunk) {
|
|
109
|
+
const end = Math.min(i + chunk, N.length - 1);
|
|
110
|
+
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
|
111
|
+
for (let j = i; j <= end; j++) {
|
|
112
|
+
const p = N[j];
|
|
113
|
+
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
|
|
114
|
+
if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
|
|
115
|
+
}
|
|
116
|
+
chunks.push({ name, N, Y, E, i0: i, i1: end, minX: minX - near, maxX: maxX + near, minZ: minZ - near, maxZ: maxZ + near });
|
|
117
|
+
box.minX = Math.min(box.minX, minX - near); box.maxX = Math.max(box.maxX, maxX + near);
|
|
118
|
+
box.minZ = Math.min(box.minZ, minZ - near); box.maxZ = Math.max(box.maxZ, maxZ + near);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const records = Object.values(rl);
|
|
122
|
+
|
|
123
|
+
function railInfo(x, z) {
|
|
124
|
+
for (const r of records) r.d = Infinity;
|
|
125
|
+
if (x < box.minX || x > box.maxX || z < box.minZ || z > box.maxZ) return rl;
|
|
126
|
+
for (const c of chunks) {
|
|
127
|
+
if (x < c.minX || x > c.maxX || z < c.minZ || z > c.maxZ) continue;
|
|
128
|
+
const R = rl[c.name], N = c.N, Y = c.Y, E = c.E;
|
|
129
|
+
for (let i = c.i0; i < c.i1; i++) {
|
|
130
|
+
const a = N[i], b = N[i + 1];
|
|
131
|
+
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1e-9;
|
|
132
|
+
const t = clamp(((x - a.x) * dx + (z - a.z) * dz) / len2, 0, 1);
|
|
133
|
+
const d = Math.hypot(x - (a.x + dx * t), z - (a.z + dz * t));
|
|
134
|
+
if (d < R.d) {
|
|
135
|
+
R.d = d;
|
|
136
|
+
R.y = lerp(Y[i], Y[i + 1], t);
|
|
137
|
+
R.w = lerp(a.w, b.w, t);
|
|
138
|
+
R.e = lerp(E[i], E[i + 1], t);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return rl;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function railDistance(x, z) {
|
|
146
|
+
const r = railInfo(x, z);
|
|
147
|
+
let d = Infinity;
|
|
148
|
+
for (const name in r) if (r[name].d < d) d = r[name].d;
|
|
149
|
+
return d;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { railInfo, railDistance, records };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// WHERE THE ROADS CROSS THE RAILS — found, not assumed: every road segment against every
|
|
156
|
+
// rail segment. Returns [{x, z, y, tx, tz}] with y = the formation there and (tx,tz) the
|
|
157
|
+
// rail's unit tangent (the axis the deck is measured along).
|
|
158
|
+
export function findCrossings(roads, lines, { minSpacing = 30 } = {}) {
|
|
159
|
+
const xings = [];
|
|
160
|
+
const hit = (a, b, c, d) => {
|
|
161
|
+
const rx = b.x - a.x, rz = b.z - a.z, sx = d.x - c.x, sz = d.z - c.z;
|
|
162
|
+
const den = rx * sz - rz * sx;
|
|
163
|
+
if (Math.abs(den) < 1e-9) return null;
|
|
164
|
+
const t = ((c.x - a.x) * sz - (c.z - a.z) * sx) / den;
|
|
165
|
+
const u = ((c.x - a.x) * rz - (c.z - a.z) * rx) / den;
|
|
166
|
+
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
|
|
167
|
+
return { x: a.x + rx * t, z: a.z + rz * t };
|
|
168
|
+
};
|
|
169
|
+
for (const road of roads) {
|
|
170
|
+
for (let i = 0; i < road.length - 1; i++) {
|
|
171
|
+
for (const name of Object.keys(lines)) {
|
|
172
|
+
const { nodes: N, y: Y } = lines[name];
|
|
173
|
+
for (let j = 0; j < N.length - 1; j++) {
|
|
174
|
+
const p = hit(road[i], road[i + 1], N[j], N[j + 1]);
|
|
175
|
+
if (p && !xings.some((q) => Math.hypot(q.x - p.x, q.z - p.z) < minSpacing)) {
|
|
176
|
+
const tx0 = N[j + 1].x - N[j].x, tz0 = N[j + 1].z - N[j].z;
|
|
177
|
+
const tl = Math.hypot(tx0, tz0) || 1;
|
|
178
|
+
xings.push({ x: p.x, z: p.z, y: nearestOnLine(N, Y, p.x, p.z).y, tx: tx0 / tl, tz: tz0 / tl });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return xings;
|
|
185
|
+
}
|
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' }; };
|
|
@@ -294,6 +560,7 @@ export function sindicateDevTools({
|
|
|
294
560
|
...stores.map(binStorePlugin),
|
|
295
561
|
perfSinkPlugin(perfSink),
|
|
296
562
|
guiPlugin(),
|
|
563
|
+
packImporterPlugin(),
|
|
297
564
|
busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
|
|
298
565
|
busPlugin({ name: 'editor', prefix: '/__editor' }),
|
|
299
566
|
editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "The Sindicate package catalog — what exists in the ecosystem and where it lives on this machine (repo paths relative to the Sites root, i.e. the games' parent dir). The dev GUI reads this via /__pack/registry and installs straight from it.",
|
|
3
|
+
"packages": [
|
|
4
|
+
{
|
|
5
|
+
"name": "rayhit-three",
|
|
6
|
+
"type": "addon",
|
|
7
|
+
"description": "Runtime mesh fracturing & physics destruction — Voronoi shattering, demolition, bombs, structural collapse.",
|
|
8
|
+
"repo": "rayhit-three"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "weather-threejs",
|
|
12
|
+
"type": "addon",
|
|
13
|
+
"description": "Stylized weather, atmosphere & time-of-day — sky, sun/moon/stars, clouds, fog, rain & snow, forecast system.",
|
|
14
|
+
"repo": "weather-threejs"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "accentimations",
|
|
18
|
+
"type": "clips",
|
|
19
|
+
"description": "The animation pack: 259 cooked clips + manifest. Dev flow consumes it via file: + registerClipPack; a packaged release is pending.",
|
|
20
|
+
"repo": "Accentimations",
|
|
21
|
+
"note": "consumed via file: dependency in dev — not yet packaged"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "kitforge",
|
|
25
|
+
"type": "addon",
|
|
26
|
+
"description": "Kit-piece cataloguer + render-to-PNG thumbnails (feeds the importer's future thumbnail grid).",
|
|
27
|
+
"repo": "kitforge",
|
|
28
|
+
"note": "not yet manifested"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "fft-water",
|
|
32
|
+
"type": "addon",
|
|
33
|
+
"description": "FFT ocean/water surface (from transport-tycoon-remake) — extraction pending.",
|
|
34
|
+
"repo": "transport-tycoon-remake",
|
|
35
|
+
"note": "planned — extraction not started"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "glodify",
|
|
39
|
+
"type": "service",
|
|
40
|
+
"description": "LOD baker (CLI/REST service) — games consume its output files; never installed as a package.",
|
|
41
|
+
"repo": "glodify",
|
|
42
|
+
"note": "service, not installable"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// THE .sindicatepackage CLI (ENGINE_DESIGN §4c) — one distributable wrapper for everything
|
|
3
|
+
// installable. Under the hood: an npm-compatible tarball (package/ prefix) with a
|
|
4
|
+
// sindicate.json manifest at the package root, so code add-ons install with plain npm and
|
|
5
|
+
// the engine tooling can read what a thing IS before installing it.
|
|
6
|
+
//
|
|
7
|
+
// sindicate pack <dir> → <name>-<version>.sindicatepackage (from
|
|
8
|
+
// package.json + sindicate.json in <dir>)
|
|
9
|
+
// sindicate inspect <file> → print the manifest
|
|
10
|
+
// sindicate install <file> [--game .] → type 'addon' = npm install the tarball into
|
|
11
|
+
// the game (dep + node_modules, bridge on boot)
|
|
12
|
+
// type 'assets'|'clips' = extract package/assets
|
|
13
|
+
// into <game>/public/assets/packs/<name>/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const [, , cmd, target, ...rest] = process.argv;
|
|
19
|
+
const opt = (name, dflt) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : dflt; };
|
|
20
|
+
const die = (m) => { console.error(`sindicate: ${m}`); process.exit(1); };
|
|
21
|
+
|
|
22
|
+
function readManifestFromTarball(file) {
|
|
23
|
+
try {
|
|
24
|
+
const out = execFileSync('tar', ['-xzOf', file, 'package/sindicate.json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
25
|
+
return JSON.parse(out.toString());
|
|
26
|
+
} catch (e) { die(`${path.basename(file)}: no readable package/sindicate.json — not a .sindicatepackage (${String(e).slice(0, 120)})`); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (cmd === 'pack') {
|
|
30
|
+
const dir = path.resolve(target ?? '.');
|
|
31
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
32
|
+
const sinPath = path.join(dir, 'sindicate.json');
|
|
33
|
+
if (!fs.existsSync(sinPath)) die(`${dir}/sindicate.json missing — add { name, version, type, description } first`);
|
|
34
|
+
const sin = JSON.parse(fs.readFileSync(sinPath, 'utf8'));
|
|
35
|
+
// npm pack builds the canonical npm tarball (honours "files", writes package/ prefix);
|
|
36
|
+
// sindicate.json rides along because it is listed in "files" (enforce that here).
|
|
37
|
+
const files = pkg.files ?? [];
|
|
38
|
+
if (files.length && !files.includes('sindicate.json')) die(`package.json "files" must include "sindicate.json" so the manifest ships`);
|
|
39
|
+
const out = execFileSync('npm', ['pack', '--silent'], { cwd: dir }).toString().trim().split('\n').pop();
|
|
40
|
+
const dest = path.join(dir, `${(sin.name ?? pkg.name).replace('/', '-')}-${pkg.version}.sindicatepackage`);
|
|
41
|
+
fs.renameSync(path.join(dir, out), dest);
|
|
42
|
+
console.log(`packed ${dest} (type: ${sin.type})`);
|
|
43
|
+
} else if (cmd === 'inspect') {
|
|
44
|
+
console.log(JSON.stringify(readManifestFromTarball(path.resolve(target)), null, 2));
|
|
45
|
+
} else if (cmd === 'install') {
|
|
46
|
+
const file = path.resolve(target);
|
|
47
|
+
const game = path.resolve(opt('--game', '.'));
|
|
48
|
+
if (!fs.existsSync(path.join(game, 'package.json'))) die(`${game} is not a game (no package.json) — pass --game <dir>`);
|
|
49
|
+
const sin = readManifestFromTarball(file);
|
|
50
|
+
if (sin.engineRange) console.log(`engine range: ${sin.engineRange} (checked at boot by the bridge)`);
|
|
51
|
+
if (sin.type === 'addon') {
|
|
52
|
+
// npm only recognises tarballs by extension (.sindicatepackage reads as a folder path
|
|
53
|
+
// → ENOTDIR), so a .tgz TWIN sits beside the original — and STAYS: npm records the
|
|
54
|
+
// dep as file:<path-to-tgz>, so deleting it would break the game's next npm install
|
|
55
|
+
const tgz = file.replace(/\.sindicatepackage$/, '.tgz');
|
|
56
|
+
fs.copyFileSync(file, tgz);
|
|
57
|
+
execFileSync('npm', ['install', tgz], { cwd: game, stdio: 'inherit' });
|
|
58
|
+
console.log(`installed addon '${sin.name}' into ${path.basename(game)} — import it from game code${sin.bridge ? `; bridge: ${sin.bridge}` : ''}`);
|
|
59
|
+
} else if (sin.type === 'assets' || sin.type === 'clips') {
|
|
60
|
+
const dest = path.join(game, 'public/assets/packs', sin.name);
|
|
61
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
62
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
63
|
+
// extract only the package/assets tree, stripping the two leading path parts
|
|
64
|
+
execFileSync('tar', ['-xzf', file, '-C', dest, '--strip-components', '2', 'package/assets'], { stdio: 'pipe' });
|
|
65
|
+
fs.writeFileSync(path.join(dest, 'pack.json'), JSON.stringify({ ...sin, installedAt: new Date().toISOString(), source: 'sindicatepackage' }, null, 2));
|
|
66
|
+
console.log(`installed ${sin.type} pack '${sin.name}' → ${dest}`);
|
|
67
|
+
} else die(`unknown type '${sin.type}' (addon | assets | clips | pack-profile)`);
|
|
68
|
+
} else {
|
|
69
|
+
console.log('usage: sindicate pack <dir> | inspect <file> | install <file> [--game <dir>]');
|
|
70
|
+
process.exit(cmd ? 1 : 0);
|
|
71
|
+
}
|