sindicate 0.7.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 +1 -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/package.json
CHANGED
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
|