sindicate 0.6.0 → 0.8.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 +3 -2
- package/src/audio/music.js +11 -1
- package/src/core/assets.js +6 -1
- package/src/systems/climbing.js +39 -1
- package/src/systems/footsteps.js +2 -1
- package/src/systems/vat.js +135 -1
- package/src/systems/vatRoster.js +149 -0
- package/src/world/flora.js +12 -6
- package/src/world/sky.js +4 -3
- package/src/world/weather.js +3 -3
- package/tools/devPlugin.js +232 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sindicate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
],
|
|
23
23
|
"exports": {
|
|
24
24
|
".": "./src/index.js",
|
|
25
|
-
"./*": "./src/*"
|
|
25
|
+
"./*": "./src/*",
|
|
26
|
+
"./tools/*": "./tools/*"
|
|
26
27
|
},
|
|
27
28
|
"files": [
|
|
28
29
|
"src",
|
package/src/audio/music.js
CHANGED
|
@@ -111,7 +111,7 @@ export class Music {
|
|
|
111
111
|
_onEnded(t) {
|
|
112
112
|
if (this._cur !== t) return;
|
|
113
113
|
if (t === this.restSleep) return; // asleep under the black; the wake cut takes over
|
|
114
|
-
if (t === this.restWake) { this._state = 'area'; this._playArea(this._area ||
|
|
114
|
+
if (t === this.restWake) { this._state = 'area'; this._playArea(this._area || Object.keys(this.areas)[0]); return; }
|
|
115
115
|
if (this._state === 'sting') {
|
|
116
116
|
const fb = this._stingFallback; this._stingFallback = null;
|
|
117
117
|
this._state = 'area'; this._playArea(fb || this._area || this._half);
|
|
@@ -126,6 +126,16 @@ export class Music {
|
|
|
126
126
|
if (this._unlocked) return;
|
|
127
127
|
this._unlocked = true;
|
|
128
128
|
this._ensureCtx();
|
|
129
|
+
// Pre-decode the catalogue's `resident` list (file names) so combat stings and
|
|
130
|
+
// exploration cuts start INSTANTLY — everything else decodes on first crossfade.
|
|
131
|
+
if (this._cat.resident?.length) {
|
|
132
|
+
const defs = [this.menu, ...this.attack, ...this.venue,
|
|
133
|
+
...Object.values(this.areas).flat(), this.restSleep, this.restWake].filter(Boolean);
|
|
134
|
+
for (const name of this._cat.resident) {
|
|
135
|
+
const t = defs.find((d) => d.url.endsWith(`/${name}`));
|
|
136
|
+
if (t) this._decode(t).catch(() => { /* pre-decode is best-effort */ });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
129
139
|
if (this.ctx.state === 'suspended') this.ctx.resume().catch(() => {});
|
|
130
140
|
}
|
|
131
141
|
|
package/src/core/assets.js
CHANGED
|
@@ -8,6 +8,11 @@ import { texture, uv } from 'three/tsl';
|
|
|
8
8
|
// Register BEFORE the first applyAtlas/instantiate call (materials are cached).
|
|
9
9
|
let atlasEmissiveHook = null;
|
|
10
10
|
export function setAtlasEmissiveHook(fn) { atlasEmissiveHook = fn; }
|
|
11
|
+
|
|
12
|
+
// The cooked-clip cache version tracks each GAME's animation sources (a rebake bumps
|
|
13
|
+
// it), not the engine — register per game: setClipCacheVersion(2). Defaults to 2
|
|
14
|
+
// (western's current rebake generation).
|
|
15
|
+
export function setClipCacheVersion(v) { CLIP_JSON_VER = v; }
|
|
11
16
|
// Patched loader from vendor/ — merges ALL FBX animation layers per stack. The
|
|
12
17
|
// engine does NOT depend on a node_modules postinstall patch.
|
|
13
18
|
import { FBXLoader } from '../vendor/FBXLoader.js';
|
|
@@ -532,7 +537,7 @@ export function applyAtlas(root, tex, { emissive = null } = {}) {
|
|
|
532
537
|
// public/assets/clips/<name>.json on first parse (self-written via /__save-clipjson)
|
|
533
538
|
// and read the JSON on every later boot. Bump CLIP_JSON_VER after changing any source
|
|
534
539
|
// animation (the name alone can't see content changes), or delete the folder.
|
|
535
|
-
|
|
540
|
+
let CLIP_JSON_VER = 2; // v2: full-pack gsbake rebake (97 clips, 2026-07-12)
|
|
536
541
|
function clipJsonName(url) {
|
|
537
542
|
let h = 5381;
|
|
538
543
|
for (let i = 0; i < url.length; i++) h = ((h << 5) + h + url.charCodeAt(i)) >>> 0;
|
package/src/systems/climbing.js
CHANGED
|
@@ -11,7 +11,10 @@ import * as THREE from 'three/webgpu';
|
|
|
11
11
|
const CLIMB_SPEED = 1.7; // m/s along the rungs (match the up/down loop cadence)
|
|
12
12
|
const ANIM_SPEED = 2; // clip playback ×; the mocap loops are slow, so 2× keeps the hands with the movement
|
|
13
13
|
const BODY_OFF = 0.30; // player root sits this far back from the ladder line (gripping side)
|
|
14
|
-
|
|
14
|
+
// Which side of the ladder the climber grips + faces is a property of the GAME's
|
|
15
|
+
// ladder props: setClimbContent({ faceSign: 1 }) — western +1, fable −1.
|
|
16
|
+
let FACE_SIGN = 1;
|
|
17
|
+
export function setClimbContent({ faceSign = null } = {}) { if (faceSign != null) FACE_SIGN = faceSign; }
|
|
15
18
|
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
16
19
|
const _axis = new THREE.Vector3(), _face = new THREE.Vector3();
|
|
17
20
|
|
|
@@ -133,4 +136,39 @@ export class Climbing {
|
|
|
133
136
|
this.mode = 'off'; this.ladder = null;
|
|
134
137
|
p.busy = false; p.vy = 0; p.airborne = false; p._visualY = p.position.y;
|
|
135
138
|
}
|
|
139
|
+
|
|
140
|
+
// ---- fable-lineage conveniences (unioned): ladder scan + input-driven mount ----
|
|
141
|
+
_ladders() { return this.game.world?.castle?.ladders || null; }
|
|
142
|
+
|
|
143
|
+
nearby() {
|
|
144
|
+
const ls = this._ladders(); if (!ls || !ls.length) return null;
|
|
145
|
+
const p = this.player.position; let best = null, bd = REACH * REACH;
|
|
146
|
+
for (const l of ls) {
|
|
147
|
+
const dy = p.y - l.base.y;
|
|
148
|
+
if (dy < -1.0 || dy > l.height + 1.0) continue;
|
|
149
|
+
const dx = l.base.x - p.x, dz = l.base.z - p.z, d = dx * dx + dz * dz;
|
|
150
|
+
if (d < bd) { bd = d; best = l; }
|
|
151
|
+
}
|
|
152
|
+
return best;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
tryMount(input) {
|
|
156
|
+
if (this.active || !input.hit('KeyE')) return false;
|
|
157
|
+
const l = this.nearby(); if (!l) return false;
|
|
158
|
+
const atTop = this.player.position.y > l.base.y + l.height - 1.2;
|
|
159
|
+
this._solve(l);
|
|
160
|
+
this.ladder = l; this.mode = 'mounting'; this.h = atTop ? l.height : 0;
|
|
161
|
+
this._place(); this.player.busy = true;
|
|
162
|
+
this.player.animator.play('climbMount', { once: true, fade: 0.12, onDone: () => { if (this.mode === 'mounting') this.mode = 'climbing'; } });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
debugTeleport() {
|
|
167
|
+
const ls = this._ladders(); if (!ls || !ls.length) { console.log('[climb] no ladders loaded yet'); return; }
|
|
168
|
+
this._dbg = ((this._dbg ?? -1) + 1) % ls.length;
|
|
169
|
+
const l = ls[this._dbg], p = this.player;
|
|
170
|
+
p.position.set(l.base.x, l.base.y, l.base.z); p._visualY = l.base.y; p.vy = 0; p._capGrounded = false;
|
|
171
|
+
console.log(`[climb] -> ladder ${this._dbg + 1}/${ls.length} ${l.file} base=(${l.base.x.toFixed(1)},${l.base.y.toFixed(1)},${l.base.z.toFixed(1)}) h=${l.height.toFixed(1)} — press E to mount`);
|
|
172
|
+
}
|
|
173
|
+
|
|
136
174
|
}
|
package/src/systems/footsteps.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Real recordings (cplomedia Footsteps pack) via audio.loadSamples/sample. Six surfaces × six variants.
|
|
5
5
|
import * as THREE from 'three/webgpu';
|
|
6
6
|
// register the road query before surface classification: setFootstepTerrain({ roadDistance })
|
|
7
|
-
let FT = { roadDistance: () => 1e9 };
|
|
7
|
+
let FT = { roadDistance: () => 1e9, classify: null }; // classify(x,z) -> surface replaces the built-in ground read
|
|
8
8
|
export function setFootstepTerrain(t) { FT = { ...FT, ...t }; }
|
|
9
9
|
|
|
10
10
|
const _fv = new THREE.Vector3();
|
|
@@ -59,6 +59,7 @@ export class Footsteps {
|
|
|
59
59
|
// a coach roof is boards, and there is no board set: 'stone' is the hard-packed one (SURFACES)
|
|
60
60
|
const d = this.game.player?.platform;
|
|
61
61
|
if (d) return d.deck.surface ?? 'stone';
|
|
62
|
+
if (FT.classify) return FT.classify(x, z); // the game's own surface model (fable: plazas/swamp/sand/snow)
|
|
62
63
|
return groundSurface(this.game, x, z);
|
|
63
64
|
}
|
|
64
65
|
|
package/src/systems/vat.js
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
// /__save-vatbin, already registered in vite.config.js). Playing once cooks everything.
|
|
22
22
|
import * as THREE from 'three/webgpu';
|
|
23
23
|
import { Fn, texture, attribute, uniform, uniformArray, vec2, int, float, floor, mod, uv } from 'three/tsl';
|
|
24
|
-
import { loadModel, instantiate, loadTexture } from '../core/assets.js';
|
|
24
|
+
import { loadModel, instantiate, loadTexture, applyAtlas, resolveModelUrl } from '../core/assets.js';
|
|
25
|
+
import { buildRetargetContext, retargetClip } from '../core/retarget.js';
|
|
26
|
+
import { mergeCharacterBody } from './entity.js';
|
|
25
27
|
|
|
26
28
|
// Stack N coat textures side-by-side into one atlas, so a herd can show a different colour per
|
|
27
29
|
// individual (a per-instance aLayer offsets U). Horizontal keeps V/flipY identical to the original.
|
|
@@ -287,6 +289,14 @@ export async function loadSpeciesVAT(def) {
|
|
|
287
289
|
console.warn(`[vat] ${def.rig.split('/').slice(-2)[0]}: no clip and no alias for "${n}" — it will play clip 0`);
|
|
288
290
|
clipIndex[n] = 0;
|
|
289
291
|
}
|
|
292
|
+
// SINGLE-TAKE RIGS (fable's seagull ships one unnamed "Take 001" reel): when no regex
|
|
293
|
+
// matched anything, bake the first source clip so the reel itself is clip 0 — the
|
|
294
|
+
// missing-pass then aliases everything onto it (flySegment clamps it to the flying part).
|
|
295
|
+
if (!baked.length && srcClips.length) {
|
|
296
|
+
const first = Object.keys(def.clips)[0] ?? 'idle';
|
|
297
|
+
clipIndex[first] = 0;
|
|
298
|
+
baked.push(trimClip(srcClips[0], first));
|
|
299
|
+
}
|
|
290
300
|
if (!baked.length) throw new Error(`no clips to bake for ${def.rig}`);
|
|
291
301
|
// idles at 15 fps — halves the rows they cost in the VAT texture, and a sway does not need 30
|
|
292
302
|
for (const c of baked) if (!/^(walk|run)$/.test(c.name)) (c.userData ??= {}).bakeFps = 15;
|
|
@@ -294,6 +304,66 @@ export async function loadSpeciesVAT(def) {
|
|
|
294
304
|
const vat = await bakeVATCached(`${def.rig}|${def.anims ?? ''}`, rig, skinned, baked);
|
|
295
305
|
vat.playFps = vat.fps * (def.playRate ?? 1);
|
|
296
306
|
|
|
307
|
+
// Single-take rigs (the seagull ships ONE "Take 001" reel: fly + perch + preen…) play the
|
|
308
|
+
// WHOLE reel — mid-air birds folded their wings ("wings in a diagonal shape around the
|
|
309
|
+
// body"). flySegment:'auto' scans the baked frames for the longest run of near-max WINGSPAN
|
|
310
|
+
// and clamps every clip window to it, so airborne playback stays inside the flying section.
|
|
311
|
+
if (Array.isArray(def.flySegment) && vat.table.length) {
|
|
312
|
+
// explicit window (frames, picked with animaltest's loop sliders) — clamp every clip to it.
|
|
313
|
+
// The END is a HINT: an arbitrary slice of a longer reel rarely lands on a perfect cycle,
|
|
314
|
+
// so the wrap popped ("cutting weirdly"). Snap to the frame near the hint whose POSE best
|
|
315
|
+
// matches the start frame (RMS over sampled verts of the baked data) → seamless loop.
|
|
316
|
+
const [fs, feHint] = def.flySegment;
|
|
317
|
+
const d = vat.data ?? vat.tex.image.data, W = vat.W, RPF = vat.rowsPerFrame;
|
|
318
|
+
const dist = (a, b) => {
|
|
319
|
+
let s = 0, n = 0;
|
|
320
|
+
for (let i = 0; i < vat.vCount; i += 7) {
|
|
321
|
+
const ia = ((a * RPF) * W + i) * 4, ib = ((b * RPF) * W + i) * 4;
|
|
322
|
+
const dx = d[ia] - d[ib], dy = d[ia + 1] - d[ib + 1], dz = d[ia + 2] - d[ib + 2];
|
|
323
|
+
s += dx * dx + dy * dy + dz * dz; n++;
|
|
324
|
+
}
|
|
325
|
+
return s / n;
|
|
326
|
+
};
|
|
327
|
+
let fe = feHint, bd = Infinity;
|
|
328
|
+
const maxF = vat.table[0].frames - 1;
|
|
329
|
+
for (let k = Math.max(fs + 8, feHint - 7); k <= Math.min(maxF, feHint + 7); k++) {
|
|
330
|
+
const dd = dist(fs, k);
|
|
331
|
+
if (dd < bd) { bd = dd; fe = k; }
|
|
332
|
+
}
|
|
333
|
+
console.log(`[vat] ${def.rig.split('/').pop()} window ${fs}..${feHint} → loop-snapped to ${fs}..${fe}`);
|
|
334
|
+
const bl = Math.max(2, fe - fs);
|
|
335
|
+
for (const e of vat.table) { e.start += fs; e.frames = Math.min(bl, e.frames - fs); }
|
|
336
|
+
} else if (def.flySegment === 'auto' && vat.table.length) {
|
|
337
|
+
const d = vat.data ?? vat.tex.image.data, W = vat.W, RPF = vat.rowsPerFrame;
|
|
338
|
+
const block = vat.table[0]; // all blocks are copies of the same take
|
|
339
|
+
const spans = [];
|
|
340
|
+
let mx = 0;
|
|
341
|
+
for (let f = 0; f < block.frames; f++) {
|
|
342
|
+
const base = (block.start + f) * RPF * W * 4;
|
|
343
|
+
let mnX = 1e9, mxX = -1e9, mnZ = 1e9, mxZ = -1e9;
|
|
344
|
+
const cnt = Math.min(vat.vCount, W * RPF);
|
|
345
|
+
for (let i = 0; i < cnt; i += 3) {
|
|
346
|
+
const x = d[base + i * 4], z = d[base + i * 4 + 2];
|
|
347
|
+
if (x < mnX) mnX = x; if (x > mxX) mxX = x;
|
|
348
|
+
if (z < mnZ) mnZ = z; if (z > mxZ) mxZ = z;
|
|
349
|
+
}
|
|
350
|
+
const s = Math.max(mxX - mnX, mxZ - mnZ);
|
|
351
|
+
spans.push(s); if (s > mx) mx = s;
|
|
352
|
+
}
|
|
353
|
+
const th = mx * 0.6; // 0.82 cut the flap DIPS (wings mid-beat) and left a 9-frame stutter;
|
|
354
|
+
// 0.6 keeps whole flap cycles in and still rejects folded-wing sections (~0.4×)
|
|
355
|
+
let bs = 0, bl = 0, cs = -1;
|
|
356
|
+
for (let f = 0; f <= spans.length; f++) {
|
|
357
|
+
const ok = f < spans.length && spans[f] >= th;
|
|
358
|
+
if (ok && cs < 0) cs = f;
|
|
359
|
+
if (!ok && cs >= 0) { if (f - cs > bl) { bl = f - cs; bs = cs; } cs = -1; }
|
|
360
|
+
}
|
|
361
|
+
if (bl >= 10) for (const e of vat.table) { e.start += bs; e.frames = bl; }
|
|
362
|
+
console.log(`[vat] ${def.rig.split('/').pop()} flySegment auto: frames ${bs}..${bs + bl} of ${block.frames}`);
|
|
363
|
+
// DEV: post the frame profile to the perf sink so the reel structure is readable from disk
|
|
364
|
+
if (import.meta.env?.DEV) { try { fetch('/__perf', { method: 'POST', body: JSON.stringify({ FLYSEG: { rig: def.rig, chosen: [bs, bs + bl], spans: spans.map((s) => +s.toFixed(2)) } }) }); } catch (e) { /* sink offline */ } }
|
|
365
|
+
}
|
|
366
|
+
|
|
297
367
|
// Size and ground the species from THE BAKE ITSELF (frame-0 posed positions) — the exact space
|
|
298
368
|
// vatMaterial renders from. Bind-space geometry.boundingBox is a trap on GLB rigs: the bind
|
|
299
369
|
// geometry sits in a different space than the posed skin, which pins def.height against the wrong
|
|
@@ -325,3 +395,67 @@ export async function loadSpeciesVAT(def) {
|
|
|
325
395
|
geometry.setAttribute('vertexId', new THREE.BufferAttribute(vid, 1));
|
|
326
396
|
return { vat, geometry, albedo, layers, scale, clipIndex, walkSpeed, runSpeed, footY: box.min.y * scale, size };
|
|
327
397
|
}
|
|
398
|
+
|
|
399
|
+
// ---- HUMANOID CROWD BAKING (unioned from fable) -------------------------------------
|
|
400
|
+
// Humanoid crowd bake (VAT_CROWD_PLAN step 1): a Synty character variant → VAT. Differs from
|
|
401
|
+
// loadSpeciesVAT in exactly two ways: (a) modular bodies are COLLAPSED to one SkinnedMesh first
|
|
402
|
+
// (mergeCharacterBody — the bake reads a single geometry), and (b) the clips are the game's
|
|
403
|
+
// SHARED anim-rig clips, retargeted onto this rig (the same buildRetargetContext/retargetClip
|
|
404
|
+
// path every live NPC uses), then baked. `def`: { model, texture, clips: {name: AnimationClip},
|
|
405
|
+
// height?, scale? }. clips insertion order defines the aClip indices (clipIndex maps names).
|
|
406
|
+
export async function loadVillagerVAT(def) {
|
|
407
|
+
// packed multi-character GLBs (dwarves, war-camp humans) use the keep-filter + explicit atlas
|
|
408
|
+
// path, exactly like Character.load — so any roster character can be baked from its own fields.
|
|
409
|
+
let rig;
|
|
410
|
+
if (def.keep) {
|
|
411
|
+
rig = await instantiate(def.model, { keep: def.keep, attach: def.attach });
|
|
412
|
+
if (def.texture) applyAtlas(rig, await loadTexture(def.texture, { flipY: def.texFlipY ?? false }));
|
|
413
|
+
} else {
|
|
414
|
+
rig = await instantiate(def.model, { texture: def.texture });
|
|
415
|
+
}
|
|
416
|
+
mergeCharacterBody(rig); // multi-part bodies → 1 mesh (no-op when already single; never throws)
|
|
417
|
+
let skinned = null;
|
|
418
|
+
let parts = 0;
|
|
419
|
+
rig.traverse((o) => {
|
|
420
|
+
if (!o.isSkinnedMesh || !o.visible) return;
|
|
421
|
+
parts++;
|
|
422
|
+
if (!skinned || o.geometry.attributes.position.count > skinned.geometry.attributes.position.count) skinned = o;
|
|
423
|
+
});
|
|
424
|
+
if (!skinned) throw new Error(`no SkinnedMesh in ${def.model}`);
|
|
425
|
+
if (parts > 1) console.warn(`[vat] ${def.model.split('/').pop()}: ${parts} skinned parts after merge — baking the largest only`);
|
|
426
|
+
skinned.normalizeSkinWeights?.();
|
|
427
|
+
const ctx = def.raw ? null : buildRetargetContext(rig);
|
|
428
|
+
const baked = [], clipIndex = {};
|
|
429
|
+
for (const [name, clip] of Object.entries(def.clips)) {
|
|
430
|
+
if (!clip) continue;
|
|
431
|
+
clipIndex[name] = baked.length;
|
|
432
|
+
// raw rigs (PolyPerfect creatures, cathedral court): the clips already target this rig's own
|
|
433
|
+
// bones — bake directly, no retarget. Clone so the bakeFps tag never leaks onto live clips.
|
|
434
|
+
const rc = def.raw ? clip.clone() : retargetClip(clip, ctx); // bakeVAT strips .position (in-place)
|
|
435
|
+
if (name !== 'walk' && name !== 'run') (rc.userData ??= {}).bakeFps = 15; // idles at 15fps — halves rows, imperceptible on sways
|
|
436
|
+
baked.push(rc);
|
|
437
|
+
}
|
|
438
|
+
if (!baked.length) throw new Error(`no clips to bake for ${def.model}`);
|
|
439
|
+
// key the bake by the RESOLVED model: the twin redirect silently swaps .fbx villagers to
|
|
440
|
+
// GLB rigs whose vertex ORDER differs — a bin cooked from the FBX rig applied to the GLB
|
|
441
|
+
// geometry renders a scrambled half-buried character (Nick's floor-flickering villager:
|
|
442
|
+
// garbled VAT instance when demoted, correct skinned rig when promoted, thrash at the
|
|
443
|
+
// boundary). Resolved key → the redirected rigs cook fresh bins on first sight.
|
|
444
|
+
const resolvedModel = await resolveModelUrl(def.model, { texture: def.texture });
|
|
445
|
+
const vat = await bakeVATCached(`${resolvedModel}|${def.keep ?? ''}`, rig, skinned, baked);
|
|
446
|
+
vat.playFps = vat.fps * (def.playRate ?? 1);
|
|
447
|
+
// size/ground in bind space, same rules as species (GLB characters are metres → scale ≈ 1;
|
|
448
|
+
// def.scale pins it exactly to the live-NPC scale for a seamless promote swap later)
|
|
449
|
+
skinned.geometry.computeBoundingBox();
|
|
450
|
+
const box = skinned.geometry.boundingBox;
|
|
451
|
+
const size = box.getSize(new THREE.Vector3());
|
|
452
|
+
const scale = def.scale ?? (def.height ?? 1.75) / (size.y || 1);
|
|
453
|
+
const walkSpeed = clipIndex.walk != null
|
|
454
|
+
? (rootMotionSpeed(baked[clipIndex.walk], scale) || footSlideSpeed(vat, clipIndex.walk, scale) || 1.4)
|
|
455
|
+
: 1.4;
|
|
456
|
+
const albedo = (Array.isArray(skinned.material) ? skinned.material[0] : skinned.material)?.map ?? null;
|
|
457
|
+
const geometry = skinned.geometry.clone();
|
|
458
|
+
const vid = new Float32Array(vat.vCount); for (let i = 0; i < vat.vCount; i++) vid[i] = i;
|
|
459
|
+
geometry.setAttribute('vertexId', new THREE.BufferAttribute(vid, 1));
|
|
460
|
+
return { vat, geometry, albedo, layers: 1, scale, clipIndex, walkSpeed, footY: box.min.y * scale, size };
|
|
461
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// VATRoster: EVERY ambient character (named NPCs, travellers, later knights/goblins/dwarves)
|
|
2
|
+
// renders as a VAT instance while "demoted" — its real skinned rig stays boot-built but hidden,
|
|
3
|
+
// and its Animator never runs. The character PROMOTES to the rig within arm's-reach distance or
|
|
4
|
+
// the moment its AI plays a clip that isn't in the baked ambient set (attacks, hit, death, chat
|
|
5
|
+
// takes, sits) — so combat/dialogue correctness is automatic. Demote reverses when far + ambient.
|
|
6
|
+
// One InstancedMesh per (model|keep|texture) variant for the whole roster: ~150 characters cost
|
|
7
|
+
// ~a dozen draws + zero animator CPU while demoted. Design: VAT_CROWD_PLAN.md (Kiwi port).
|
|
8
|
+
import * as THREE from 'three/webgpu';
|
|
9
|
+
import { vatMaterial, loadVillagerVAT } from './vat.js';
|
|
10
|
+
// the ambient bake set + clip subsetting are GAME data:
|
|
11
|
+
// setRosterContent({ bakeClips, idleClips, clipSubset })
|
|
12
|
+
let RC = { bakeClips: [], idleClips: [], clipSubset: (c) => c };
|
|
13
|
+
export function setRosterContent(c) { RC = { ...RC, ...c }; }
|
|
14
|
+
|
|
15
|
+
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _p = new THREE.Vector3(), _s = new THREE.Vector3(), _UP = new THREE.Vector3(0, 1, 0);
|
|
16
|
+
|
|
17
|
+
export class VATRoster {
|
|
18
|
+
constructor(game) {
|
|
19
|
+
this.game = game;
|
|
20
|
+
this._pending = []; // [{ proxy, char }] until build()
|
|
21
|
+
this.variants = new Map(); // key → { mesh, geo, mat, aClip, v, n }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Register a character. Before build(): queued, its variant is baked on the loading screen.
|
|
25
|
+
// After build() (runtime spawns — warbands, knight patrols, respawns): claims a free slot on
|
|
26
|
+
// an EXISTING variant immediately; unknown variants return null (the char just stays real —
|
|
27
|
+
// never bake or create meshes mid-play). opts: { bakeClips, promoteR, demoteR } — enemies use
|
|
28
|
+
// a wide promote radius so their attached weapons (not in the bake) are never missed up close.
|
|
29
|
+
register(char, opts = {}) {
|
|
30
|
+
const at = Array.isArray(char.attach) ? char.attach.join('+') : (char.attach ?? '');
|
|
31
|
+
const key = `${char.modelUrl}|${char.keep ?? ''}|${at}|${char.textureUrl ?? ''}`;
|
|
32
|
+
const proxy = { key, slot: -1, variant: null, ready: false, shown: false, promoteR: opts.promoteR ?? 8, demoteR: opts.demoteR ?? 10 };
|
|
33
|
+
if (this.built) {
|
|
34
|
+
const variant = this.variants.get(key);
|
|
35
|
+
if (!variant) {
|
|
36
|
+
// unknown variant after boot (late populations — hold-dwarves): queue it; the char stays
|
|
37
|
+
// real (proxy.ready=false) until the owner calls buildLate() once its cohort is loaded.
|
|
38
|
+
this._pending.push({ proxy, char, bakeClips: opts.bakeClips ?? null, raw: opts.raw ?? false });
|
|
39
|
+
return proxy;
|
|
40
|
+
}
|
|
41
|
+
const slot = variant.free.length ? variant.free.pop() : (variant.used < variant.n ? variant.used++ : -1);
|
|
42
|
+
if (slot < 0) return null; // variant full — char stays real
|
|
43
|
+
proxy.variant = variant; proxy.slot = slot; proxy.ready = true;
|
|
44
|
+
return proxy;
|
|
45
|
+
}
|
|
46
|
+
this._pending.push({ proxy, char, bakeClips: opts.bakeClips ?? null, raw: opts.raw ?? false });
|
|
47
|
+
return proxy;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A removed character (enemy death cleanup, despawned patrol member) returns its slot.
|
|
51
|
+
release(proxy) {
|
|
52
|
+
if (!proxy?.ready) return;
|
|
53
|
+
this.hide(proxy);
|
|
54
|
+
proxy.ready = false;
|
|
55
|
+
proxy.variant.free.push(proxy.slot);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Bake every registered variant + create its InstancedMesh (capacity = registrations + spare
|
|
59
|
+
// for same-variant late joiners). Run on the loading screen BEFORE the boot warm render.
|
|
60
|
+
async build(clips) {
|
|
61
|
+
if (!this._pending.length) return;
|
|
62
|
+
const bakeClips = RC.clipSubset(clips, RC.bakeClips);
|
|
63
|
+
const byKey = new Map();
|
|
64
|
+
for (const e of this._pending) { let l = byKey.get(e.proxy.key); if (!l) byKey.set(e.proxy.key, (l = [])); l.push(e); }
|
|
65
|
+
await Promise.all([...byKey.entries()].map(async ([key, entries]) => {
|
|
66
|
+
const c = entries[0].char;
|
|
67
|
+
let v = null;
|
|
68
|
+
try {
|
|
69
|
+
v = await loadVillagerVAT({ model: c.modelUrl, texture: c.textureUrl, keep: c.keep, attach: c.attach, texFlipY: c.texFlipY, clips: entries[0].bakeClips ?? bakeClips, scale: c.scale, raw: entries[0].raw });
|
|
70
|
+
} catch (err) { console.warn('[roster] bake failed (chars stay real):', key.split('|')[0].split('/').pop(), err); return; }
|
|
71
|
+
const n = entries.length + 4;
|
|
72
|
+
const geo = v.geometry.clone();
|
|
73
|
+
const aClip = new Float32Array(n);
|
|
74
|
+
const aClipAttr = new THREE.InstancedBufferAttribute(aClip, 1);
|
|
75
|
+
aClipAttr.setUsage(THREE.DynamicDrawUsage);
|
|
76
|
+
geo.setAttribute('aClip', aClipAttr);
|
|
77
|
+
const aPhase = new Float32Array(n), aTint = new Float32Array(n * 3);
|
|
78
|
+
for (let i = 0; i < n; i++) { aPhase[i] = Math.random() * 6; aTint[i * 3] = aTint[i * 3 + 1] = aTint[i * 3 + 2] = 1; }
|
|
79
|
+
geo.setAttribute('aPhase', new THREE.InstancedBufferAttribute(aPhase, 1));
|
|
80
|
+
geo.setAttribute('aTint', new THREE.InstancedBufferAttribute(aTint, 3));
|
|
81
|
+
const mat = vatMaterial(v.vat, v.albedo, 1);
|
|
82
|
+
const mesh = new THREE.InstancedMesh(geo, mat, n); // count stays n; parked slots are scale-0 (draw nothing)
|
|
83
|
+
// r184 initialises instance matrices to IDENTITY — an unwritten slot would render the rig
|
|
84
|
+
// at scale 1 at the origin (a 180-unit colossus for cm-baked FBX variants). Park them all.
|
|
85
|
+
_m.compose(_p.set(0, -900, 0), _q.identity(), _s.set(0, 0, 0));
|
|
86
|
+
for (let i = 0; i < n; i++) mesh.setMatrixAt(i, _m);
|
|
87
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
88
|
+
mesh.frustumCulled = false;
|
|
89
|
+
mesh.castShadow = false; mesh.receiveShadow = false;
|
|
90
|
+
mesh.name = `roster:${key.split('|')[0].split('/').pop()}`;
|
|
91
|
+
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
|
|
92
|
+
this.game.scene.add(mesh);
|
|
93
|
+
const variant = { mesh, geo, mat, aClip, v, n, used: 0, free: [] };
|
|
94
|
+
this.variants.set(key, variant);
|
|
95
|
+
for (const e of entries) { e.proxy.variant = variant; e.proxy.slot = variant.used++; e.proxy.ready = true; }
|
|
96
|
+
}));
|
|
97
|
+
this._pending.length = 0;
|
|
98
|
+
this.built = true;
|
|
99
|
+
console.log(`[roster] ${[...this.variants.values()].reduce((a, x) => a + x.used, 0)} characters VAT-backed · ${this.variants.size} variants/draws`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Is this animator clip renderable as a VAT instance? idles map to a baked idle,
|
|
103
|
+
// walk/run map directly; anything else forces the real rig (promote).
|
|
104
|
+
mappable(name) { return !name || name.startsWith('idle') || name === 'walk' || name === 'run' || name === 'sprint'; }
|
|
105
|
+
|
|
106
|
+
_clipIndex(v, name) {
|
|
107
|
+
if (!name) return v.clipIndex.idle ?? 0;
|
|
108
|
+
if (name === 'walk') return v.clipIndex.walk ?? 0;
|
|
109
|
+
if (name === 'run' || name === 'sprint') return v.clipIndex.run ?? v.clipIndex.walk ?? 0;
|
|
110
|
+
if (name.startsWith('idle')) {
|
|
111
|
+
if (v.clipIndex[name] != null) return v.clipIndex[name];
|
|
112
|
+
let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; // unbaked idle → stable stand-in
|
|
113
|
+
return v.clipIndex[RC.idleClips[h % RC.idleClips.length]] ?? 0;
|
|
114
|
+
}
|
|
115
|
+
return v.clipIndex.idle ?? 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Demoted character → write its instance (position/heading/clip). Called from Character.update.
|
|
119
|
+
write(proxy, x, y, z, heading, clipName) {
|
|
120
|
+
const variant = proxy.variant;
|
|
121
|
+
_q.setFromAxisAngle(_UP, heading);
|
|
122
|
+
const sc = variant.v.scale;
|
|
123
|
+
variant.mesh.setMatrixAt(proxy.slot, _m.compose(_p.set(x, y - variant.v.footY, z), _q, _s.set(sc, sc, sc)));
|
|
124
|
+
variant.mesh.instanceMatrix.needsUpdate = true;
|
|
125
|
+
const ci = this._clipIndex(variant.v, clipName);
|
|
126
|
+
if (variant.aClip[proxy.slot] !== ci) { variant.aClip[proxy.slot] = ci; variant.geo.getAttribute('aClip').needsUpdate = true; }
|
|
127
|
+
proxy.shown = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Promoted (or dead) → park the instance (scale-0 draws nothing; the rig takes over).
|
|
131
|
+
hide(proxy) {
|
|
132
|
+
if (!proxy.shown) return;
|
|
133
|
+
proxy.shown = false;
|
|
134
|
+
const variant = proxy.variant;
|
|
135
|
+
variant.mesh.setMatrixAt(proxy.slot, _m.compose(_p.set(0, -900, 0), _q.identity(), _s.set(0, 0, 0)));
|
|
136
|
+
variant.mesh.instanceMatrix.needsUpdate = true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build variants queued AFTER boot (late populations). New meshes/pipelines are created
|
|
140
|
+
// mid-play, so call this while a background loading window is already open (the dwarves
|
|
141
|
+
// load inside phase-2's shadow) — never on a travel frame.
|
|
142
|
+
async buildLate(clips) {
|
|
143
|
+
if (!this._pending.length) return;
|
|
144
|
+
this.built = false; // reuse build()'s queue path
|
|
145
|
+
await this.build(clips);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
update(dt) { for (const variant of this.variants.values()) variant.mat.userData.uTime.value += dt; }
|
|
149
|
+
}
|
package/src/world/flora.js
CHANGED
|
@@ -68,8 +68,14 @@ export function clumpField(x, z, salt = TREE_SALT) {
|
|
|
68
68
|
return w;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// moisture/forest fields are GAME data — register per game: setFloraFields({ moistureAt, forestness }).
|
|
72
|
+
// Defaults: the desert-low moisture model below, and no forest.
|
|
73
|
+
export function setFloraFields({ moistureAt: m = null, forestness: f = null } = {}) {
|
|
74
|
+
if (m) moistureAt = m;
|
|
75
|
+
if (f) forestness = f;
|
|
76
|
+
}
|
|
77
|
+
/** analytic moisture 0..1 — desert-low default: faint drift, a lusher corridor along the creek */
|
|
78
|
+
export let moistureAt = function moistureAtDefault(x, z) {
|
|
73
79
|
let m = 0.12 + TERRAIN.fbm(x + 191, z - 87, 3, 2, 0.5, 0.012) * 0.1;
|
|
74
80
|
const ri = TERRAIN.riverInfo(x, z);
|
|
75
81
|
if (ri.dist < ri.w + 30) m += 0.35 * (1 - Math.max(0, ri.dist - ri.w) / 30);
|
|
@@ -79,9 +85,9 @@ export function moistureAt(x, z) {
|
|
|
79
85
|
}
|
|
80
86
|
m -= Math.max(0, (TERRAIN.heightAt(x, z) - 12) * 0.012);
|
|
81
87
|
return Math.min(1, Math.max(0, m));
|
|
82
|
-
}
|
|
88
|
+
};
|
|
83
89
|
|
|
84
|
-
/** no forest
|
|
85
|
-
export function
|
|
90
|
+
/** default: no forest (a game registers its own via setFloraFields) */
|
|
91
|
+
export let forestness = function forestnessDefault(x, z) {
|
|
86
92
|
return 0;
|
|
87
|
-
}
|
|
93
|
+
};
|
package/src/world/sky.js
CHANGED
|
@@ -42,7 +42,8 @@ function blendPhase(hour, a, b, out, t) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export class Sky {
|
|
45
|
-
constructor(scene) {
|
|
45
|
+
constructor(scene, { hemiGround = 0xc9b18a, ambientLift = 1.5 } = {}) {
|
|
46
|
+
this._hemiGround = hemiGround; this._ambientLift = ambientLift;
|
|
46
47
|
this.scene = scene;
|
|
47
48
|
this.hour = 10; // start mid-morning
|
|
48
49
|
this.speed = 24 / 900; // game-hours per real second (15-minute full day)
|
|
@@ -105,7 +106,7 @@ export class Sky {
|
|
|
105
106
|
this.sun.shadow.bias = -0.0004;
|
|
106
107
|
scene.add(this.sun, this.sun.target);
|
|
107
108
|
|
|
108
|
-
this.hemi = new THREE.HemisphereLight(0xbdd3e8,
|
|
109
|
+
this.hemi = new THREE.HemisphereLight(0xbdd3e8, this._hemiGround, 0.85); // ground bounce (game option: desert sand / Albion grass)
|
|
109
110
|
scene.add(this.hemi);
|
|
110
111
|
|
|
111
112
|
// sun/moon discs — the sun gets a warm gold core (0xfff4cc read as pure white through the
|
|
@@ -333,7 +334,7 @@ export class Sky {
|
|
|
333
334
|
this.sun.color.copy(NIGHT.sun).lerp(p.sun, dayness); // EASE the key-light colour across the horizon crossing — the old hard `dayness>0.5` switch snapped the ground colour in one frame at ~sunset (Nick: "at 9:15 the ground colour changed in a blink")
|
|
334
335
|
this.sun.intensity = lerp(NIGHT.sunI, p.sunI, dayness);
|
|
335
336
|
|
|
336
|
-
this.hemi.intensity = p.ambI *
|
|
337
|
+
this.hemi.intensity = p.ambI * this._ambientLift; // ambient lift (game option: desert wood needs 1.5; Albion sits at 1)
|
|
337
338
|
this.hemi.color.copy(p.sky).lerp(WHITE, 0.4);
|
|
338
339
|
|
|
339
340
|
const discR = this.skyR * 0.94; // inside the dome AND the far plane (LOW clips past 200m)
|
package/src/world/weather.js
CHANGED
|
@@ -17,12 +17,12 @@ const BOX = 28; // disk radius around the player (m)
|
|
|
17
17
|
const TOP = 18, BOT = 5; // column extent above / below the player (drops pass through eye level)
|
|
18
18
|
const SPAN = TOP + BOT;
|
|
19
19
|
const FALL = 30; // rain fall speed (m/s); snow is ~7× slower
|
|
20
|
-
const SNOWLINE = 500; // terrain height (m) where precipitation turns to snow — above every mesa: it never snows on Dustwater
|
|
21
20
|
const STORM_GREY = new THREE.Color(0x4a4f57);
|
|
22
21
|
|
|
23
22
|
export class Weather {
|
|
24
|
-
constructor(scene, { shelterObjects = null, indoorAt = null } = {}) {
|
|
23
|
+
constructor(scene, { shelterObjects = null, indoorAt = null, snowline = 500 } = {}) {
|
|
25
24
|
this._shelterObjects = shelterObjects; this._indoorAt = indoorAt;
|
|
25
|
+
this._snowline = snowline; // terrain height (m) where precipitation turns to snow
|
|
26
26
|
this.scene = scene;
|
|
27
27
|
this.intensity = 0; // 0 clear … 1 storm (smoothed)
|
|
28
28
|
this.target = 0;
|
|
@@ -230,7 +230,7 @@ export class Weather {
|
|
|
230
230
|
this.exposure += (this._expTarget - this.exposure) * Math.min(1, dt * 2.5);
|
|
231
231
|
|
|
232
232
|
const cold = this._snowForce != null ? this._snowForce
|
|
233
|
-
: world?.groundAt ? THREE.MathUtils.smoothstep(world.groundAt(playerPos.x, playerPos.z),
|
|
233
|
+
: world?.groundAt ? THREE.MathUtils.smoothstep(world.groundAt(playerPos.x, playerPos.z), this._snowline - 8, this._snowline + 8) : 0;
|
|
234
234
|
this._snow += (cold - this._snow) * Math.min(1, dt * 0.5);
|
|
235
235
|
this.uSnow.value = this._snow;
|
|
236
236
|
// cloud deck LEADS precipitation: full-heavy while rain is still building (x1.75), lingers
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// sindicateDevTools(config) — the engine's dev-server tooling suite, extracted from the
|
|
2
|
+
// western game's vite config. Returns an array of vite plugins (all `apply: 'serve'` —
|
|
3
|
+
// none of this exists in a production build):
|
|
4
|
+
//
|
|
5
|
+
// • guarded SOURCE-WRITE endpoints — bench pages persist tuned data straight into the
|
|
6
|
+
// game's data files (grip/rig/map/LOD... whatever the game declares)
|
|
7
|
+
// • COOK-TO-FILE stores — the game bakes its own caches (VAT bins, nav grids, parsed
|
|
8
|
+
// animation clips, world cook bins, map PNGs) and every later boot is a file read.
|
|
9
|
+
// Each store serves its directory itself: the dirs are excluded from vite's watcher
|
|
10
|
+
// (writes must not full-reload the page), and vite 8's watcher-fed public registry
|
|
11
|
+
// 404s files created after startup — so we bypass it.
|
|
12
|
+
// • PERF SINK — [PERF]/FREEZE samples append to a JSONL on disk, readable without the
|
|
13
|
+
// console (a backgrounded tab pauses rAF and CDP is flaky; disk is neither)
|
|
14
|
+
// • EDITOR + GAME COMMAND BUSES — queue {js|op} snippets over HTTP; the live page polls,
|
|
15
|
+
// executes, posts results back. Scripted playtests and editor drives with curl.
|
|
16
|
+
//
|
|
17
|
+
// Usage (a game's vite.config.js):
|
|
18
|
+
// import { sindicateDevTools } from 'sindicate/tools/devPlugin.js';
|
|
19
|
+
// plugins: [...sindicateDevTools({
|
|
20
|
+
// sourceWrites: [{ route: '/__save-grip', file: 'src/game/data/gripTuning.json',
|
|
21
|
+
// json: true, mustInclude: 'revolver' }],
|
|
22
|
+
// assetLists: [{ route: '/__list-assets', dir: 'public/assets/world', ext: 'fbx' }],
|
|
23
|
+
// perfSink: process.env.MY_PERF_SINK || '/tmp/mygame-perf.jsonl',
|
|
24
|
+
// })]
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
|
|
27
|
+
// Guard for the source-writing endpoints: only same-machine pages may write, and the
|
|
28
|
+
// payload must look like the file it claims to be — an empty/garbage body used to be
|
|
29
|
+
// written verbatim (truncating a committed src file to 0 bytes and breaking the boot via
|
|
30
|
+
// HMR), and cross-origin simple POSTs from any open webpage could reach these routes.
|
|
31
|
+
function guardOk(req, res, body, checks) {
|
|
32
|
+
const origin = req.headers.origin;
|
|
33
|
+
if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
|
34
|
+
res.statusCode = 403; res.end('cross-origin write refused'); return false;
|
|
35
|
+
}
|
|
36
|
+
if (!body || body.length < 20) { res.statusCode = 400; res.end('empty body refused'); return false; }
|
|
37
|
+
for (const [label, ok] of checks) {
|
|
38
|
+
if (!ok) { res.statusCode = 400; res.end(`payload failed check: ${label}`); return false; }
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
44
|
+
const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c)));
|
|
45
|
+
});
|
|
46
|
+
const sendJson = (res, code, obj) => {
|
|
47
|
+
res.statusCode = code; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(obj));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// One guarded write-through-to-a-source-file endpoint (grip/rig/map/LOD tuners).
|
|
51
|
+
function sourceWritePlugin({ route, file, json = false, mustInclude = null }) {
|
|
52
|
+
return {
|
|
53
|
+
name: `sindicate-write:${route}`,
|
|
54
|
+
apply: 'serve',
|
|
55
|
+
configureServer(server) {
|
|
56
|
+
server.middlewares.use(route, (req, res) => {
|
|
57
|
+
if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
|
|
58
|
+
let body = '';
|
|
59
|
+
req.on('data', (c) => { body += c; });
|
|
60
|
+
req.on('end', () => {
|
|
61
|
+
try {
|
|
62
|
+
const checks = mustInclude ? [[`payload mentions '${mustInclude}'`, body.includes(mustInclude)]] : [];
|
|
63
|
+
if (!guardOk(req, res, body, checks)) return;
|
|
64
|
+
if (json) JSON.parse(body); // never write a malformed file
|
|
65
|
+
fs.writeFileSync(file, body);
|
|
66
|
+
res.statusCode = 200; res.end('ok');
|
|
67
|
+
} catch (e) { res.statusCode = 500; res.end(String(e)); }
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Directory listing for editor asset palettes.
|
|
75
|
+
function assetListPlugin({ route, dir, ext }) {
|
|
76
|
+
const re = new RegExp(`\\.${ext}$`, 'i');
|
|
77
|
+
return {
|
|
78
|
+
name: `sindicate-list:${route}`,
|
|
79
|
+
apply: 'serve',
|
|
80
|
+
configureServer(server) {
|
|
81
|
+
server.middlewares.use(route, (req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const files = fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => f.replace(re, '')).sort();
|
|
84
|
+
sendJson(res, 200, files);
|
|
85
|
+
} catch (e) { res.statusCode = 500; res.end('[]'); }
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Serve + accept a cook-to-file store (bins/JSON/PNG the game bakes for itself).
|
|
92
|
+
function binStorePlugin({ name, serveRoute, saveRoute, dir, nameRe, contentType }) {
|
|
93
|
+
return {
|
|
94
|
+
name: `sindicate-store:${name}`,
|
|
95
|
+
apply: 'serve',
|
|
96
|
+
configureServer(server) {
|
|
97
|
+
server.middlewares.use(serveRoute, (req, res, next) => {
|
|
98
|
+
const file = (req.url || '').split('?')[0].replace(/^\//, '');
|
|
99
|
+
if (!nameRe.test(file)) return next();
|
|
100
|
+
try {
|
|
101
|
+
const buf = fs.readFileSync(`${dir}/${file}`);
|
|
102
|
+
res.setHeader('content-type', typeof contentType === 'function' ? contentType(file) : contentType);
|
|
103
|
+
res.end(buf);
|
|
104
|
+
} catch (e) { res.statusCode = 404; res.end('not cooked'); }
|
|
105
|
+
});
|
|
106
|
+
server.middlewares.use(saveRoute, async (req, res) => {
|
|
107
|
+
if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
|
|
108
|
+
const file = new URL(req.url, 'http://x').searchParams.get('name') || '';
|
|
109
|
+
if (!nameRe.test(file)) { res.statusCode = 400; return res.end('bad name'); }
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
112
|
+
fs.writeFileSync(`${dir}/${file}`, await readBody(req));
|
|
113
|
+
res.statusCode = 200; res.end('ok');
|
|
114
|
+
} catch (e) { res.statusCode = 500; res.end(String(e)); }
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// JSONL telemetry sink with size rotation.
|
|
121
|
+
function perfSinkPlugin(sinkPath) {
|
|
122
|
+
return {
|
|
123
|
+
name: 'sindicate-perf-sink',
|
|
124
|
+
apply: 'serve',
|
|
125
|
+
configureServer(server) {
|
|
126
|
+
server.middlewares.use('/__perf', (req, res) => {
|
|
127
|
+
if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
|
|
128
|
+
let body = '';
|
|
129
|
+
req.on('data', (c) => { body += c; });
|
|
130
|
+
req.on('end', () => {
|
|
131
|
+
try {
|
|
132
|
+
try { if (fs.statSync(sinkPath).size > 8_000_000) fs.renameSync(sinkPath, sinkPath + '.1'); } catch { /* no sink yet */ }
|
|
133
|
+
fs.appendFileSync(sinkPath, body.trim() + '\n'); res.statusCode = 200; res.end('ok');
|
|
134
|
+
} catch (e) { res.statusCode = 500; res.end(String(e)); }
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A poll/take command bus: POST {…} to /cmd → the page polls /poll → posts /result →
|
|
142
|
+
// collect via /take?id=N. `originGuard` restricts /cmd to same-machine pages.
|
|
143
|
+
function busPlugin({ name, prefix, originGuard = false }) {
|
|
144
|
+
return {
|
|
145
|
+
name: `sindicate-bus:${name}`,
|
|
146
|
+
apply: 'serve',
|
|
147
|
+
configureServer(server) {
|
|
148
|
+
const queue = [];
|
|
149
|
+
const results = new Map();
|
|
150
|
+
let nextId = 1;
|
|
151
|
+
server.middlewares.use(`${prefix}/cmd`, async (req, res) => {
|
|
152
|
+
if (req.method !== 'POST') return sendJson(res, 405, { err: 'POST only' });
|
|
153
|
+
if (originGuard) {
|
|
154
|
+
const origin = req.headers.origin;
|
|
155
|
+
if (origin && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return sendJson(res, 403, { err: 'cross-origin refused' });
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const cmd = JSON.parse((await readBody(req)).toString());
|
|
159
|
+
cmd.id = nextId++; queue.push(cmd);
|
|
160
|
+
if (queue.length > 40) queue.splice(0, queue.length - 40);
|
|
161
|
+
sendJson(res, 200, { id: cmd.id });
|
|
162
|
+
} catch (e) { sendJson(res, 400, { err: String(e) }); }
|
|
163
|
+
});
|
|
164
|
+
server.middlewares.use(`${prefix}/poll`, (req, res) => sendJson(res, 200, { cmds: queue.splice(0) }));
|
|
165
|
+
server.middlewares.use(`${prefix}/result`, async (req, res) => {
|
|
166
|
+
if (req.method !== 'POST') return sendJson(res, 405, { err: 'POST only' });
|
|
167
|
+
try {
|
|
168
|
+
const r = JSON.parse((await readBody(req)).toString());
|
|
169
|
+
results.set(r.id, r);
|
|
170
|
+
if (results.size > 100) results.delete(results.keys().next().value);
|
|
171
|
+
sendJson(res, 200, { ok: 1 });
|
|
172
|
+
} catch (e) { sendJson(res, 400, { err: String(e) }); }
|
|
173
|
+
});
|
|
174
|
+
server.middlewares.use(`${prefix}/take`, (req, res) => {
|
|
175
|
+
const id = Number(new URL(req.url, 'http://x').searchParams.get('id') || 0);
|
|
176
|
+
if (!results.has(id)) return sendJson(res, 404, { pending: true });
|
|
177
|
+
const r = results.get(id); results.delete(id); sendJson(res, 200, r);
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Editor screenshot drop (ring-limited) + baked-map store.
|
|
184
|
+
function editorShotsPlugin({ shotsDir, mapsDir }) {
|
|
185
|
+
return {
|
|
186
|
+
name: 'sindicate-editor-shots',
|
|
187
|
+
apply: 'serve',
|
|
188
|
+
configureServer(server) {
|
|
189
|
+
server.middlewares.use('/__save-editorshot', async (req, res) => {
|
|
190
|
+
if (req.method !== 'POST') { res.statusCode = 405; return res.end('POST only'); }
|
|
191
|
+
const name = new URL(req.url, 'http://x').searchParams.get('name') || '';
|
|
192
|
+
if (!/^[\w.-]+\.png$/.test(name)) { res.statusCode = 400; return res.end('bad name'); }
|
|
193
|
+
try {
|
|
194
|
+
fs.mkdirSync(shotsDir, { recursive: true });
|
|
195
|
+
fs.writeFileSync(`${shotsDir}/${name}`, await readBody(req));
|
|
196
|
+
const shots = fs.readdirSync(shotsDir).filter((f) => f.endsWith('.png'));
|
|
197
|
+
if (shots.length > 150) {
|
|
198
|
+
shots.map((f) => [f, fs.statSync(`${shotsDir}/${f}`).mtimeMs]).sort((a, b) => a[1] - b[1])
|
|
199
|
+
.slice(0, shots.length - 150).forEach(([f]) => { try { fs.unlinkSync(`${shotsDir}/${f}`); } catch { /* raced */ } });
|
|
200
|
+
}
|
|
201
|
+
res.end('ok');
|
|
202
|
+
} catch (e) { res.statusCode = 500; res.end(String(e)); }
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function sindicateDevTools({
|
|
209
|
+
sourceWrites = [],
|
|
210
|
+
assetLists = [],
|
|
211
|
+
perfSink = process.env.SINDICATE_PERF_SINK || '/tmp/sindicate-perf.jsonl',
|
|
212
|
+
cookStores = null, // override to add/remove stores; null = the standard set below
|
|
213
|
+
editorShotsDir = 'public/editor-shots',
|
|
214
|
+
mapsDir = 'public/assets/maps',
|
|
215
|
+
} = {}) {
|
|
216
|
+
const stores = cookStores ?? [
|
|
217
|
+
{ name: 'vat', serveRoute: '/assets/vat', saveRoute: '/__save-vatbin', dir: 'public/assets/vat', nameRe: /^[\w-]+\.bin$/, contentType: 'application/octet-stream' },
|
|
218
|
+
{ name: 'nav', serveRoute: '/assets/nav', saveRoute: '/__save-navbin', dir: 'public/assets/nav', nameRe: /^[\w.-]+\.bin$/, contentType: 'application/octet-stream' },
|
|
219
|
+
{ name: 'clips', serveRoute: '/assets/clips', saveRoute: '/__save-clipjson', dir: 'public/assets/clips', nameRe: /^[\w.-]+\.json$/, contentType: 'application/json' },
|
|
220
|
+
{ name: 'cook', serveRoute: '/assets/cook', saveRoute: '/__save-cookbin', dir: 'public/assets/cook', nameRe: /^[\w.-]+\.(bin|json)$/, contentType: 'application/octet-stream' },
|
|
221
|
+
{ name: 'maps', serveRoute: '/assets/maps', saveRoute: '/__save-mappng', dir: mapsDir, nameRe: /^[\w.-]+\.(png|json)$/, contentType: (f) => (f.endsWith('.json') ? 'application/json' : 'image/png') },
|
|
222
|
+
];
|
|
223
|
+
return [
|
|
224
|
+
...sourceWrites.map(sourceWritePlugin),
|
|
225
|
+
...assetLists.map(assetListPlugin),
|
|
226
|
+
...stores.map(binStorePlugin),
|
|
227
|
+
perfSinkPlugin(perfSink),
|
|
228
|
+
busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
|
|
229
|
+
busPlugin({ name: 'editor', prefix: '/__editor' }),
|
|
230
|
+
editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),
|
|
231
|
+
];
|
|
232
|
+
}
|