sindicate 0.7.0 → 0.9.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.7.0",
3
+ "version": "0.9.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",
@@ -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