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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sindicate",
3
- "version": "0.6.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",
@@ -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 || 'desert'); return; }
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
 
@@ -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
- const CLIP_JSON_VER = 2; // v2: full-pack gsbake rebake (97 clips, 2026-07-12)
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;
@@ -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
- const FACE_SIGN = 1; // +1 for western's ladder props (fable used −1); flips which side the climber grips + faces
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
  }
@@ -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
 
@@ -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
+ }
@@ -68,8 +68,14 @@ export function clumpField(x, z, salt = TREE_SALT) {
68
68
  return w;
69
69
  }
70
70
 
71
- /** analytic moisture 0..1 desert-low: faint drift, a lusher corridor along the creek */
72
- export function moistureAt(x, z) {
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 on the desert plain always 0 (signature kept for the field consumers) */
85
- export function forestness(x, z) {
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, 0xc9b18a, 0.85); // ground bounce = desert sand, not Albion grass
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 * 1.5; // desert lift: dark western wood crushed to black at Albion ambient levels
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)
@@ -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), SNOWLINE - 8, SNOWLINE + 8) : 0;
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
+ }