sindicate 0.13.0 → 0.15.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/CHANGELOG.md +69 -0
- package/docs/plans/kiwi-road-recon.md +97 -0
- package/docs/plans/road-system.md +142 -0
- package/docs/plans/three-cities.md +43 -0
- package/package.json +3 -2
- package/src/world/design.js +72 -0
- package/src/world/heightfield.js +146 -0
- package/src/world/kit.js +924 -0
- package/src/world/permanentWay.js +161 -0
- package/src/world/placement.js +309 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +270 -0
- package/src/world/roadNetwork.js +118 -0
- package/src/world/roadTerrain.js +263 -0
- package/src/world/waterLevels.js +95 -0
- package/src/world/waterMeshes.js +168 -0
- package/src/world/worldBase.js +493 -0
- package/tools/ExportKiwiRoadMaterials.cs +145 -0
- package/tools/ExportKiwiRoadRefs.cs +94 -0
- package/tools/buildKiwiRoadsPack.mjs +36 -0
- package/tools/designer-mcp.mjs +103 -0
- package/tools/designer.js +316 -0
- package/tools/devPlugin.js +57 -0
- package/tools/fbxToGlb.mjs +115 -0
- package/tools/glbWrite.mjs +62 -0
- package/tools/rebuildSimpleRoundabouts.mjs +211 -0
- package/tools/roadKitAnalyze.mjs +366 -0
- package/tools/truthToAssemblies.mjs +160 -0
- package/tools/unityMeshToGlb.mjs +95 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
package/src/world/kit.js
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
// THE KIT REGISTRIES + DOOR/LADDER MECHANISM (world splits, step 5a) — the live shared
|
|
2
|
+
// state of a stamped world and the machinery that animates it, moved verbatim from
|
|
3
|
+
// western's town.js. Identity is the contract: the coach/train systems, the TileField,
|
|
4
|
+
// the weather shelter closure and the region streamer all hold these ARRAYS by reference
|
|
5
|
+
// — one module instance, ever. The stamper itself (buildTown/buildArea + the content
|
|
6
|
+
// regexes) follows in 5b; games re-export these from their town module so every existing
|
|
7
|
+
// importer keeps its binding.
|
|
8
|
+
import * as THREE from 'three/webgpu';
|
|
9
|
+
import { texture, uv } from 'three/tsl';
|
|
10
|
+
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
|
|
11
|
+
import { loadModel, loadTexture, resolveModelUrl } from '../core/assets.js';
|
|
12
|
+
import { flattenToGeometry } from './scatter.js';
|
|
13
|
+
import { lampLight } from './lampField.js';
|
|
14
|
+
|
|
15
|
+
// ── the registries ────────────────────────────────────────────────────────────────────
|
|
16
|
+
export const colliders = []; // fence capsules + tree circles (everything else is in the BVH)
|
|
17
|
+
// Every building mesh, baked into the collision BVH (world.build) and raycast by weather.
|
|
18
|
+
export const buildingObjects = [];
|
|
19
|
+
// Working doors: one record per DOORWAY (not per building). The game wires an
|
|
20
|
+
// E-interactable to each; updateDoors() eases the swing.
|
|
21
|
+
export const doors = [];
|
|
22
|
+
// CLIMBABLE LADDERS: one record per continuous ladder, in world space, for the Climbing
|
|
23
|
+
// controller. { base:Vector3, quat:Quaternion, height:number }.
|
|
24
|
+
export const ladders = [];
|
|
25
|
+
// The slab that shuts an opening. Its own array, NOT `colliders`: there are 130-odd of
|
|
26
|
+
// them, they never move, and collide() walks `colliders` linearly 200-400 times a frame.
|
|
27
|
+
// Bucketed instead (indexDoors/doorsAt), so a door costs a Map lookup, not a scan.
|
|
28
|
+
export const doorColliders = [];
|
|
29
|
+
// { key, geometry, mat, part } — filled by the stamper's makeDoors, drained by buildDoorBatches
|
|
30
|
+
export const leafQueue = [];
|
|
31
|
+
|
|
32
|
+
// ── door swing tunables ───────────────────────────────────────────────────────────────
|
|
33
|
+
const BAT_OPEN = 1.7; // ~97 deg — a batwing thrown wide; it has no frame to hit
|
|
34
|
+
const DOOR_OPEN = 1.5; // ~86 deg — a door in a jamb buries itself in its own wall past 90
|
|
35
|
+
const DOOR_SECS = 0.6; // the whole swing, at a constant rate (the coach's idiom)
|
|
36
|
+
|
|
37
|
+
// ── geometry helpers ──────────────────────────────────────────────────────────────────
|
|
38
|
+
export const det3 = (m) => m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9])
|
|
39
|
+
+ m[8] * (m[1] * m[6] - m[2] * m[5]);
|
|
40
|
+
|
|
41
|
+
export function flipWinding(g) {
|
|
42
|
+
for (const attr of Object.values(g.attributes)) {
|
|
43
|
+
const a = attr.array, s = attr.itemSize;
|
|
44
|
+
for (let i = 0; i < attr.count; i += 3) {
|
|
45
|
+
for (let c = 0; c < s; c++) {
|
|
46
|
+
const i1 = (i + 1) * s + c, i2 = (i + 2) * s + c;
|
|
47
|
+
const t = a[i1]; a[i1] = a[i2]; a[i2] = t;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
attr.needsUpdate = true;
|
|
51
|
+
}
|
|
52
|
+
return g;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// geometry with `m` baked in, ready to merge into a positive-determinant parent
|
|
56
|
+
export function bake(base, m) {
|
|
57
|
+
const g = base.clone().applyMatrix4(m);
|
|
58
|
+
const flat = g.index ? g.toNonIndexed() : g;
|
|
59
|
+
return det3(m.elements) < 0 ? flipWinding(flat) : flat;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── ladders ───────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Resolve gathered ladder prop matrices → world climb records, MERGING collinear stacked
|
|
64
|
+
// segments (same x,z, stepped y) into one tall ladder. Measures each unique GLB's
|
|
65
|
+
// Y-extent once for the per-segment height (× the instance's Y-scale).
|
|
66
|
+
export async function buildLadders(insts) {
|
|
67
|
+
if (!insts.length) return [];
|
|
68
|
+
const localH = {};
|
|
69
|
+
for (const li of insts) {
|
|
70
|
+
const key = `${li.dir}/${li.file}`;
|
|
71
|
+
if (localH[key] != null) continue;
|
|
72
|
+
try { const flat = flattenToGeometry(await loadModel(`${key}.fbx`)); flat.geometry.computeBoundingBox(); const bb = flat.geometry.boundingBox; localH[key] = bb.max.y - bb.min.y; }
|
|
73
|
+
catch { localH[key] = 3.0; }
|
|
74
|
+
}
|
|
75
|
+
const _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3();
|
|
76
|
+
const recs = insts.map((li) => {
|
|
77
|
+
li.m.decompose(_p, _q, _s);
|
|
78
|
+
return { base: _p.clone(), quat: _q.clone(), height: (localH[`${li.dir}/${li.file}`] ?? 3.0) * Math.abs(_s.y) };
|
|
79
|
+
});
|
|
80
|
+
const out = [], used = new Array(recs.length).fill(false);
|
|
81
|
+
for (let i = 0; i < recs.length; i++) {
|
|
82
|
+
if (used[i]) continue;
|
|
83
|
+
const group = [recs[i]]; used[i] = true;
|
|
84
|
+
for (let j = i + 1; j < recs.length; j++) {
|
|
85
|
+
if (!used[j] && Math.hypot(recs[j].base.x - recs[i].base.x, recs[j].base.z - recs[i].base.z) < 0.6) { group.push(recs[j]); used[j] = true; }
|
|
86
|
+
}
|
|
87
|
+
group.sort((a, b) => a.base.y - b.base.y);
|
|
88
|
+
const bot = group[0], top = group[group.length - 1];
|
|
89
|
+
out.push({ base: bot.base, quat: bot.quat, height: (top.base.y + top.height) - bot.base.y });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── door batches ──────────────────────────────────────────────────────────────────────
|
|
95
|
+
export function buildDoorBatches(scene) {
|
|
96
|
+
const byMat = new Map();
|
|
97
|
+
for (const l of leafQueue) {
|
|
98
|
+
if (!byMat.has(l.mat)) byMat.set(l.mat, []);
|
|
99
|
+
byMat.get(l.mat).push(l);
|
|
100
|
+
}
|
|
101
|
+
let batches = 0, leaves = 0;
|
|
102
|
+
for (const [mat, list] of byMat) {
|
|
103
|
+
const geos = new Map();
|
|
104
|
+
for (const l of list) if (!geos.has(l.key)) geos.set(l.key, l.geometry);
|
|
105
|
+
let verts = 0;
|
|
106
|
+
for (const g of geos.values()) verts += g.attributes.position.count;
|
|
107
|
+
try {
|
|
108
|
+
const bm = new THREE.BatchedMesh(list.length, verts, 0, mat); // 0 indices: bake() hands us non-indexed
|
|
109
|
+
const ids = new Map();
|
|
110
|
+
for (const [k, g] of geos) ids.set(k, bm.addGeometry(g));
|
|
111
|
+
for (const l of list) {
|
|
112
|
+
const iid = bm.addInstance(ids.get(l.key));
|
|
113
|
+
bm.setMatrixAt(iid, l.part.rest);
|
|
114
|
+
l.part.batch = bm; l.part.iid = iid;
|
|
115
|
+
}
|
|
116
|
+
bm.castShadow = true; bm.receiveShadow = true;
|
|
117
|
+
bm.name = `doors:${batches}`;
|
|
118
|
+
bm.userData.noBVH = true; // they MOVE — collision must never freeze them into the BVH
|
|
119
|
+
scene.add(bm);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
// A doorless county is a worse bug than a slow one: fall back to a Mesh per leaf.
|
|
122
|
+
console.warn('[doors] batching failed — falling back to one mesh per leaf', e);
|
|
123
|
+
for (const l of list) {
|
|
124
|
+
const mesh = new THREE.Mesh(l.geometry, mat);
|
|
125
|
+
mesh.castShadow = true; mesh.receiveShadow = true;
|
|
126
|
+
mesh.name = `door:${l.key}`;
|
|
127
|
+
mesh.userData.noBVH = true;
|
|
128
|
+
mesh.matrixAutoUpdate = false;
|
|
129
|
+
mesh.matrix.copy(l.part.rest);
|
|
130
|
+
mesh.updateMatrixWorld(true);
|
|
131
|
+
scene.add(mesh);
|
|
132
|
+
l.part.mesh = mesh;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
leaves += list.length;
|
|
136
|
+
batches++;
|
|
137
|
+
}
|
|
138
|
+
leafQueue.length = 0;
|
|
139
|
+
return { batches, leaves };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── the shut-door slabs, bucketed into an 8 m grid (one Map lookup inside collide()) ──
|
|
143
|
+
const DOOR_CELL = 8;
|
|
144
|
+
const _doorGrid = new Map();
|
|
145
|
+
const _noDoors = [];
|
|
146
|
+
const cellOf = (x, z) => (Math.floor(x / DOOR_CELL) + 2048) * 4096 + (Math.floor(z / DOOR_CELL) + 2048);
|
|
147
|
+
export function indexDoors() {
|
|
148
|
+
_doorGrid.clear();
|
|
149
|
+
for (const c of doorColliders) {
|
|
150
|
+
const r = Math.hypot(c.hx, c.hz) + 1.2; // slab half-diagonal + the fattest body radius
|
|
151
|
+
for (let gx = Math.floor((c.x - r) / DOOR_CELL); gx <= Math.floor((c.x + r) / DOOR_CELL); gx++) {
|
|
152
|
+
for (let gz = Math.floor((c.z - r) / DOOR_CELL); gz <= Math.floor((c.z + r) / DOOR_CELL); gz++) {
|
|
153
|
+
const k = (gx + 2048) * 4096 + (gz + 2048);
|
|
154
|
+
let a = _doorGrid.get(k);
|
|
155
|
+
if (!a) _doorGrid.set(k, a = []);
|
|
156
|
+
a.push(c);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return doorColliders.length;
|
|
161
|
+
}
|
|
162
|
+
export const doorsAt = (x, z) => _doorGrid.get(cellOf(x, z)) ?? _noDoors;
|
|
163
|
+
|
|
164
|
+
// ── the swing ─────────────────────────────────────────────────────────────────────────
|
|
165
|
+
const _m4 = new THREE.Matrix4();
|
|
166
|
+
const _ry4 = new THREE.Matrix4();
|
|
167
|
+
export function updateDoors(dt) {
|
|
168
|
+
for (const d of doors) {
|
|
169
|
+
const want = d.open ? 1 : 0;
|
|
170
|
+
if (d.t === want) continue; // at rest: 130 float compares, and nothing else
|
|
171
|
+
d.t = Math.max(0, Math.min(1, d.t + Math.sign(want - d.t) * (dt / DOOR_SECS)));
|
|
172
|
+
const e = d.t * d.t * (3 - 2 * d.t);
|
|
173
|
+
// `sign` is which side the opener stood on; a door swings AWAY from that side. The
|
|
174
|
+
// saloon batwings are double-acting (no jamb) so they go the same way, thrown wider.
|
|
175
|
+
const a = -(d.bat ? BAT_OPEN : DOOR_OPEN) * d.sign * e;
|
|
176
|
+
for (const p of d.parts) {
|
|
177
|
+
_m4.copy(p.rest).multiply(_ry4.makeRotationY(p.dir * a));
|
|
178
|
+
if (p.batch) p.batch.setMatrixAt(p.iid, _m4);
|
|
179
|
+
else if (p.mesh) { p.mesh.matrix.copy(_m4); p.mesh.matrixWorldNeedsUpdate = true; }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Toggle a door. Wired to the game's E-interactable; safe for anything else to call.
|
|
185
|
+
export function toggleDoor(d, fromX, fromZ) {
|
|
186
|
+
d.open = !d.open;
|
|
187
|
+
if (d.open) {
|
|
188
|
+
// which side of the opening the opener is standing on (recomputed per toggle)
|
|
189
|
+
d.sign = Math.sign((fromX - d.x) * d.nx + (fromZ - d.z) * d.nz) || 1;
|
|
190
|
+
d.col.hx = 0; // hx<=0 IS the "off" switch in collide — the opening is a hole
|
|
191
|
+
} else {
|
|
192
|
+
d.col.hx = d.colHx; // shut: the slab is solid again
|
|
193
|
+
}
|
|
194
|
+
return d.open;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
// ═══ THE KIT CONTRACT (world splits 5b) — the game-content knobs the stamper machinery
|
|
199
|
+
// reads: naming regexes for the publisher's kit, the hinge-hardware table, asset dirs and
|
|
200
|
+
// atlases. A game registers once (setKitContent in its town module); the machinery below
|
|
201
|
+
// never hardcodes a file name.
|
|
202
|
+
let KC = {
|
|
203
|
+
doorRe: /Door/i, // leaves pulled OUT of the weld onto hinges
|
|
204
|
+
shellRe: null, // …except these: shells that don't render from inside stay welded
|
|
205
|
+
paneRe: /_Window(?=_\d+$)|_Glass$/i, // glass welded back ONTO its leaf
|
|
206
|
+
batRe: /Swinging/i, // double-acting pairs (no jamb, thrown wide)
|
|
207
|
+
hingeHw: {}, // file-local hinge direction per leaf name, measured off the meshes
|
|
208
|
+
shellBody: null, // kit pieces that enclose a ROOM (get an interior box)
|
|
209
|
+
fencePanel: null, // props that lay a low jumpable capsule along their +X span
|
|
210
|
+
dirs: {}, defaultDir: null, // asset dirs by layout keyword
|
|
211
|
+
atlases: {}, // atlas per dir keyword
|
|
212
|
+
texOverrides: [], // [{ re, tex }] file-name texture overrides (signboards)
|
|
213
|
+
materialParams: { roughness: 0.9, metalness: 0 },
|
|
214
|
+
// ── 5c: the area stamper's knobs ──
|
|
215
|
+
heightAt: () => 0, // the game's terrain — every seat/snap/drop test reads it
|
|
216
|
+
groundOff: 0.45, // the pack's floor plane above the demo's dirt (see buildingLift)
|
|
217
|
+
snap: 0.30, // landing within this of the dirt IS a ground prop
|
|
218
|
+
bed: 0.22, // how deep a skirt/foundation beds below the dirt
|
|
219
|
+
noCollide: /$^/, // scatter named Deco — excluded from the collision BVH
|
|
220
|
+
propDelete: new Set(), // per-position ledger: demo junk flagged for the bin
|
|
221
|
+
propGround: new Set(), // per-position ledger: explicit flat-on-the-dirt
|
|
222
|
+
airborneOk: /$^/, // authored height is deliberate (hangs, leans, is held)
|
|
223
|
+
areaGroundOnly: /$^/, // wheels ride the dirt (floor-datum areas only)
|
|
224
|
+
pathRe: /$^/, // raised path pieces culled when no landform bears them
|
|
225
|
+
// ── 5d: the town stamper's content (one authored settlement the game calls home) ──
|
|
226
|
+
roadDistance: () => Infinity, // the game's road field — building nudge guard-rail
|
|
227
|
+
town: null, // { layout, pieces, dressing, fenceRuns, extras, map, mapAnchor,
|
|
228
|
+
// buildingTex(b, pieces)?, softShell?, airborneOk, groundOnly, outdoorOnly }
|
|
229
|
+
};
|
|
230
|
+
export function setKitContent(c = {}) { Object.assign(KC, c); }
|
|
231
|
+
export const kitContent = () => KC;
|
|
232
|
+
|
|
233
|
+
const doorSplit = (name) => KC.doorRe.test(name) && !(KC.shellRe && KC.shellRe.test(name));
|
|
234
|
+
export const resolveDir = (e) => KC.dirs[e.dir] ?? KC.dirs[KC.defaultDir];
|
|
235
|
+
export const resolveTex = (e) => e.tex ?? (KC.texOverrides.find((o) => o.re.test(e.f ?? ''))?.tex ?? KC.atlases[e.dir] ?? KC.atlases[KC.defaultDir]);
|
|
236
|
+
|
|
237
|
+
// A fence panel placed as a prop still has to push back: name-excluded from the BVH, it
|
|
238
|
+
// gets one low jumpable capsule along the panel's local +X span instead.
|
|
239
|
+
export function addFenceCapsule(f, mat, r) {
|
|
240
|
+
if (!KC.fencePanel || !KC.fencePanel.test(f)) return;
|
|
241
|
+
const e = mat.elements, ax = e[0], az = e[2], al = Math.hypot(ax, az) || 1;
|
|
242
|
+
const half = Math.max(1.2, r ?? 1.6), cx = e[12], cz = e[14];
|
|
243
|
+
colliders.push({ x1: cx - (ax / al) * half, z1: cz - (az / al) * half, x2: cx + (ax / al) * half, z2: cz + (az / al) * half, r: 0.3, low: true });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// WHICH STILE HANGS: the registered hardware table first; otherwise the stile FARTHEST
|
|
247
|
+
// from the kit piece's own origin (the outer one — mirrored fronts open away from each other).
|
|
248
|
+
function hingeHint(name, geo) {
|
|
249
|
+
const hw = KC.hingeHw[name];
|
|
250
|
+
if (hw) return hw;
|
|
251
|
+
geo.computeBoundingBox();
|
|
252
|
+
const b = geo.boundingBox;
|
|
253
|
+
const wide = (b.max.x - b.min.x) >= (b.max.z - b.min.z);
|
|
254
|
+
const lo = wide ? b.min.x : b.min.z, hi = wide ? b.max.x : b.max.z;
|
|
255
|
+
const s = Math.abs(lo) > Math.abs(hi) ? -1 : 1; // which end is farther out
|
|
256
|
+
return wide ? [s, 0] : [0, s];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── geometry: one merged geometry per DISTINCT building, shared by its tiled copies ──
|
|
260
|
+
const _geoCache = new Map();
|
|
261
|
+
const _mat = new Map();
|
|
262
|
+
|
|
263
|
+
// Merge a whole building's kit pieces into ONE geometry in building-local space. Door
|
|
264
|
+
// leaves are pulled OUT of the weld (doorSplit) onto hinge pivots; panes weld back onto
|
|
265
|
+
// their leaf; mirrored (negative-determinant) pieces get their winding fixed by bake();
|
|
266
|
+
// shellBody pieces record their interior box so props can be claimed by the right room.
|
|
267
|
+
export async function buildingGeometry(id, pieces) {
|
|
268
|
+
const key = pieces.map((p) => `${p.dir}/${p.f}:${p.m.join(',')}`).join('|');
|
|
269
|
+
let entry = _geoCache.get(key);
|
|
270
|
+
if (entry !== undefined) return entry;
|
|
271
|
+
const m = new THREE.Matrix4();
|
|
272
|
+
const parts = [];
|
|
273
|
+
const interiors = []; // building-local XZ boxes of the SHELL pieces — the rooms, wall to wall
|
|
274
|
+
const leaves = []; // { name, geometry, hint } in BUILDING-LOCAL space
|
|
275
|
+
for (const p of pieces) {
|
|
276
|
+
try {
|
|
277
|
+
const master = await loadModel(`${resolveDir(p)}/${p.f}.fbx`, { texture: resolveTex(p) });
|
|
278
|
+
const flat = flattenToGeometry(master, { split: doorSplit });
|
|
279
|
+
if (!flat) continue;
|
|
280
|
+
const pm = m.fromArray(p.m);
|
|
281
|
+
const mine = [], panes = [];
|
|
282
|
+
for (const d of flat.parts ?? []) {
|
|
283
|
+
const pane = KC.paneRe.test(d.name);
|
|
284
|
+
const hint = pane ? null : hingeHint(d.name, d.geometry); // read the hardware FILE-local
|
|
285
|
+
const g = bake(d.geometry, pm);
|
|
286
|
+
if (g.attributes.color) g.deleteAttribute('color');
|
|
287
|
+
if (pane) panes.push({ owner: d.name.replace(KC.paneRe, ''), geometry: g });
|
|
288
|
+
else mine.push({ name: d.name, geometry: g, hint: new THREE.Vector3(hint[0], 0, hint[1]).transformDirection(pm) });
|
|
289
|
+
}
|
|
290
|
+
for (const pane of panes) {
|
|
291
|
+
const leaf = mine.find((l) => l.name === pane.owner);
|
|
292
|
+
if (leaf) leaf.glass = pane.geometry;
|
|
293
|
+
else parts.push(pane.geometry); // a pane with no leaf: weld it back
|
|
294
|
+
}
|
|
295
|
+
for (const l of mine) {
|
|
296
|
+
if (l.glass) l.geometry = BufferGeometryUtils.mergeGeometries([l.geometry, l.glass], false);
|
|
297
|
+
leaves.push(l);
|
|
298
|
+
}
|
|
299
|
+
const g = bake(flat.geometry, pm);
|
|
300
|
+
// cross-file merge: attribute sets and index state must match, so normalise both
|
|
301
|
+
if (g.attributes.color) g.deleteAttribute('color');
|
|
302
|
+
if (KC.shellBody && KC.shellBody.test(p.f)) {
|
|
303
|
+
g.computeBoundingBox();
|
|
304
|
+
const b = g.boundingBox;
|
|
305
|
+
interiors.push({ x0: b.min.x, z0: b.min.z, x1: b.max.x, z1: b.max.z });
|
|
306
|
+
}
|
|
307
|
+
parts.push(g);
|
|
308
|
+
} catch (e) { console.warn(`[town] piece ${id}/${p.f} failed`, e); }
|
|
309
|
+
}
|
|
310
|
+
const geo = parts.length ? BufferGeometryUtils.mergeGeometries(parts, false) : null;
|
|
311
|
+
if (geo) geo.computeBoundingSphere();
|
|
312
|
+
entry = { geo, key, doorways: clusterDoorways(leaves), interiors };
|
|
313
|
+
_geoCache.set(key, entry);
|
|
314
|
+
return entry;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ONE DOORWAY PER GROUP OF LEAVES: two leaves share a doorway when they stand side by
|
|
318
|
+
// side (centres closer than their half widths + 0.35 m of jamb) AND their heights overlap.
|
|
319
|
+
function clusterDoorways(leaves) {
|
|
320
|
+
if (!leaves.length) return [];
|
|
321
|
+
const info = leaves.map((l) => {
|
|
322
|
+
const box = new THREE.Box3().setFromBufferAttribute(l.geometry.attributes.position);
|
|
323
|
+
// hypot of the horizontal extents, not the wider one: chamfered-corner doors hang at 45°
|
|
324
|
+
return { l, box, c: box.getCenter(new THREE.Vector3()), w: Math.hypot(box.max.x - box.min.x, box.max.z - box.min.z) };
|
|
325
|
+
});
|
|
326
|
+
const up = info.map((_, i) => i);
|
|
327
|
+
const find = (i) => (up[i] === i ? i : (up[i] = find(up[i])));
|
|
328
|
+
for (let i = 0; i < info.length; i++) {
|
|
329
|
+
for (let j = i + 1; j < info.length; j++) {
|
|
330
|
+
const a = info[i], b = info[j];
|
|
331
|
+
if (a.box.min.y > b.box.max.y - 0.1 || b.box.min.y > a.box.max.y - 0.1) continue;
|
|
332
|
+
if (Math.hypot(a.c.x - b.c.x, a.c.z - b.c.z) > (a.w + b.w) / 2 + 0.35) continue;
|
|
333
|
+
up[find(i)] = find(j);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const groups = new Map();
|
|
337
|
+
info.forEach((v, i) => {
|
|
338
|
+
const r = find(i);
|
|
339
|
+
if (!groups.has(r)) groups.set(r, []);
|
|
340
|
+
groups.get(r).push(v);
|
|
341
|
+
});
|
|
342
|
+
return [...groups.values()].map(doorway);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// One doorway, in BUILDING-LOCAL space. Cached with the merged geometry, so every tiled
|
|
346
|
+
// copy of a plot shares these leaf geometries — which lets them share one batch.
|
|
347
|
+
function doorway(g) {
|
|
348
|
+
const single = g.length === 1;
|
|
349
|
+
const mid = new THREE.Vector3();
|
|
350
|
+
for (const i of g) mid.add(i.c);
|
|
351
|
+
mid.divideScalar(g.length);
|
|
352
|
+
// The SPAN is the line the leaves hang along — not axis-aligned in general.
|
|
353
|
+
let sx, sz;
|
|
354
|
+
if (single) { sx = g[0].l.hint.x; sz = g[0].l.hint.z; }
|
|
355
|
+
else { sx = g[g.length - 1].c.x - g[0].c.x; sz = g[g.length - 1].c.z - g[0].c.z; }
|
|
356
|
+
const sl = Math.hypot(sx, sz) || 1;
|
|
357
|
+
sx /= sl; sz /= sl;
|
|
358
|
+
const leaves = [];
|
|
359
|
+
let halfSpan = 0, y0 = Infinity, y1 = -Infinity;
|
|
360
|
+
for (const i of g) {
|
|
361
|
+
const b = i.box;
|
|
362
|
+
y0 = Math.min(y0, b.min.y); y1 = Math.max(y1, b.max.y);
|
|
363
|
+
// A PAIR hinges on its OUTER stiles; a SINGLE on the end its hardware points at.
|
|
364
|
+
let bestS = -Infinity, bestAbs = -1;
|
|
365
|
+
for (const cx of [b.min.x, b.max.x]) {
|
|
366
|
+
for (const cz of [b.min.z, b.max.z]) {
|
|
367
|
+
const s = (cx - mid.x) * sx + (cz - mid.z) * sz;
|
|
368
|
+
if (single ? s > bestS : Math.abs(s) > bestAbs) { bestS = s; bestAbs = Math.abs(s); }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// keep the hinge ON the span line through the leaf's centre
|
|
372
|
+
const perp = (i.c.x - mid.x) * -sz + (i.c.z - mid.z) * sx;
|
|
373
|
+
const hx = mid.x + sx * bestS + -sz * perp;
|
|
374
|
+
const hz = mid.z + sz * bestS + sx * perp;
|
|
375
|
+
halfSpan = Math.max(halfSpan, Math.abs(bestS));
|
|
376
|
+
leaves.push({
|
|
377
|
+
name: i.l.name,
|
|
378
|
+
geometry: i.l.geometry.clone().translate(-hx, 0, -hz), // hinge-local: the frame the batch instances
|
|
379
|
+
hx, hz,
|
|
380
|
+
dir: Math.sign(bestS) || 1, // which side of the opening it hangs on
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return { leaves, mid, sx, sz, halfSpan, y0, y1, bat: g.some((i) => KC.batRe.test(i.l.name)) };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Stand this building's doorways up in the world: leaves become instances in a shared
|
|
387
|
+
// batch, each carrying its own REST matrix; the doorway gets its switchable slab collider.
|
|
388
|
+
export function makeDoors(bldMesh, doorways, floorY, mat, key) {
|
|
389
|
+
bldMesh.updateWorldMatrix(true, false);
|
|
390
|
+
const ry = bldMesh.rotation.y;
|
|
391
|
+
const cr = Math.cos(ry), sr = Math.sin(ry);
|
|
392
|
+
const out = [];
|
|
393
|
+
for (let k = 0; k < doorways.length; k++) {
|
|
394
|
+
const d = doorways[k];
|
|
395
|
+
const world = bldMesh.localToWorld(new THREE.Vector3(d.mid.x, 0, d.mid.z));
|
|
396
|
+
const wsx = d.sx * cr + d.sz * sr; // span, rotated into world
|
|
397
|
+
const wsz = -d.sx * sr + d.sz * cr;
|
|
398
|
+
const col = {
|
|
399
|
+
x: world.x, z: world.z,
|
|
400
|
+
cs: wsx, sn: -wsz, // collide's box: +x axis is (cs, -sn)
|
|
401
|
+
hx: Math.max(d.halfSpan, 0.6), hz: 0.18, // a thin slab across the opening
|
|
402
|
+
y0: floorY + d.y0, y1: floorY + d.y1, // THE LEAF'S OWN BAND (upstairs doors)
|
|
403
|
+
};
|
|
404
|
+
doorColliders.push(col);
|
|
405
|
+
const parts = [];
|
|
406
|
+
for (let i = 0; i < d.leaves.length; i++) {
|
|
407
|
+
const l = d.leaves[i];
|
|
408
|
+
const h = bldMesh.localToWorld(new THREE.Vector3(l.hx, 0, l.hz));
|
|
409
|
+
const rest = new THREE.Matrix4().makeRotationY(ry).setPosition(h.x, h.y, h.z);
|
|
410
|
+
const part = { rest, dir: l.dir, batch: null, iid: -1, mesh: null };
|
|
411
|
+
parts.push(part);
|
|
412
|
+
// key by the GEOMETRY CACHE entry, so all copies of a plot share one leaf geometry
|
|
413
|
+
leafQueue.push({ key: `${key}#${k}.${i}`, geometry: l.geometry, mat, part });
|
|
414
|
+
}
|
|
415
|
+
out.push({
|
|
416
|
+
x: world.x, z: world.z, y: floorY + d.y0, y0: floorY + d.y0, y1: floorY + d.y1,
|
|
417
|
+
nx: -wsz, nz: wsx, // doorway normal = perpendicular to the span
|
|
418
|
+
bat: d.bat,
|
|
419
|
+
parts, col, colHx: col.hx, open: false, t: 0, sign: 1,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ONE material per (atlas, uv-flip) for the whole town — a new material is a new WebGPU
|
|
426
|
+
// pipeline. The flip must match the RESOLVED master format (GLB twins: flipY false).
|
|
427
|
+
export async function townMaterial(texUrl, url) {
|
|
428
|
+
const isGLB = /\.glb$/i.test(await resolveModelUrl(url, { texture: texUrl }));
|
|
429
|
+
const key = `${texUrl}|${isGLB}`;
|
|
430
|
+
if (!_mat.has(key)) {
|
|
431
|
+
const tex = await loadTexture(texUrl, isGLB ? { flipY: false } : {});
|
|
432
|
+
const tm = new THREE.MeshStandardNodeMaterial({ map: tex, ...KC.materialParams });
|
|
433
|
+
tm.emissiveNode = lampLight(texture(tex, uv()).rgb); // real lamp light on the shell + its props
|
|
434
|
+
_mat.set(key, tm);
|
|
435
|
+
}
|
|
436
|
+
return _mat.get(key);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// One integer-metre key per prop position — the ledgers (KC.propDelete / KC.propGround)
|
|
440
|
+
// are keyed on it.
|
|
441
|
+
export const propKey = (x, z) => `${Math.round(x)},${Math.round(z)}`;
|
|
442
|
+
|
|
443
|
+
// ── THE GROUND DATUM ─────────────────────────────────────────────────────────────────────────
|
|
444
|
+
// A kit is authored with the building FLOOR at local y = 0 and the GROUND below it (Synty:
|
|
445
|
+
// ≈ 0.45 m — foundation skirts to −0.667, porch posts to −0.567, deck steps placed at −0.447
|
|
446
|
+
// so the dirt meets the bottom step). Seating local 0 on the terrain buries the whole town.
|
|
447
|
+
// So: LIFT each building onto its ground plane. KC.groundOff is that datum, and each building
|
|
448
|
+
// derives its own lift from its REAL merged geometry (a well/outhouse/water tower has no
|
|
449
|
+
// skirt: min.y ≈ 0, so it must NOT rise, or it floats).
|
|
450
|
+
export const buildingLift = (geo) => {
|
|
451
|
+
geo.computeBoundingBox();
|
|
452
|
+
const lo = geo.boundingBox?.min.y ?? 0; // lowest point in building-local space
|
|
453
|
+
// Seat the geometry bottom KC.bed metres into the dirt, but never raise the floor more than
|
|
454
|
+
// KC.groundOff (deep foundation skirts would otherwise lift the boardwalk out of reach of its
|
|
455
|
+
// steps). CAN go negative: a building authored floating above its plot origin must be PULLED
|
|
456
|
+
// DOWN onto the ground — a `max(0, …)` here left one hanging in the air.
|
|
457
|
+
return Math.min(KC.groundOff, -lo - KC.bed);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// THE AREA STAMPER (world splits 5c) — stamps one authored AREA (pieces/buildings/props/fences,
|
|
461
|
+
// matrices in metres, area-local) into the world at `at` {x, z, ry, skip?}. The same pipeline
|
|
462
|
+
// the town uses: merged building geometry via buildingGeometry, doors on hinges, instanced
|
|
463
|
+
// prop batches, fence capsules, ladders. Returns { lampPositions } for the game's lamp field.
|
|
464
|
+
export async function buildArea(scene, area, at) {
|
|
465
|
+
const cos = Math.cos(at.ry ?? 0), sin = Math.sin(at.ry ?? 0);
|
|
466
|
+
const toWorld = (lx, lz) => [at.x + lx * cos + lz * sin, at.z - lx * sin + lz * cos];
|
|
467
|
+
const R = new THREE.Matrix4().makeRotationY(at.ry ?? 0);
|
|
468
|
+
const lampPositions = [];
|
|
469
|
+
const floors = [];
|
|
470
|
+
let built = 0;
|
|
471
|
+
|
|
472
|
+
for (const b of area.buildings ?? []) {
|
|
473
|
+
const pieces = area.pieces?.[b.id] ?? [];
|
|
474
|
+
if (!pieces.length) { console.warn(`[outpost] no pieces for ${area.id}/${b.id}`); continue; }
|
|
475
|
+
const { geo, key, doorways } = await buildingGeometry(`${area.id}:${b.id}`, pieces);
|
|
476
|
+
if (!geo) continue;
|
|
477
|
+
const [bx, bz] = toWorld(b.x, b.z);
|
|
478
|
+
let y = KC.heightAt(bx, bz);
|
|
479
|
+
const r = (b.collider ?? 6) * 0.7;
|
|
480
|
+
for (const [dx, dz] of [[r, 0], [-r, 0], [0, r], [0, -r]]) y = Math.min(y, KC.heightAt(bx + dx, bz + dz));
|
|
481
|
+
y = Math.max(y, KC.heightAt(bx, bz) - 0.8);
|
|
482
|
+
|
|
483
|
+
const mat = await townMaterial(resolveTex(pieces[0]), `${resolveDir(pieces[0])}/${pieces[0].f}.fbx`);
|
|
484
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
485
|
+
const floorY = y + buildingLift(geo);
|
|
486
|
+
mesh.position.set(bx, floorY, bz);
|
|
487
|
+
mesh.rotation.y = (b.ry ?? 0) + (at.ry ?? 0);
|
|
488
|
+
mesh.castShadow = true; mesh.receiveShadow = true;
|
|
489
|
+
mesh.name = `bld:${area.id}_${b.id}`;
|
|
490
|
+
scene.add(mesh);
|
|
491
|
+
buildingObjects.push(mesh);
|
|
492
|
+
geo.computeBoundingBox(); // the REAL box, not a circle — see the town builder
|
|
493
|
+
const bb = geo.boundingBox;
|
|
494
|
+
floors.push({
|
|
495
|
+
x: bx, z: bz, y: floorY, ry: (b.ry ?? 0) + (at.ry ?? 0),
|
|
496
|
+
hx: Math.max(bb.max.x, -bb.min.x) + 0.4,
|
|
497
|
+
hz: Math.max(bb.max.z, -bb.min.z) + 0.4,
|
|
498
|
+
});
|
|
499
|
+
for (const d of makeDoors(mesh, doorways, floorY, mat, key)) {
|
|
500
|
+
d.name = b.name ?? area.name;
|
|
501
|
+
doors.push(d);
|
|
502
|
+
}
|
|
503
|
+
built++;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
for (const f of area.fences ?? []) {
|
|
507
|
+
const [x1, z1] = toWorld(f.x1, f.z1);
|
|
508
|
+
const [x2, z2] = toWorld(f.x2, f.z2);
|
|
509
|
+
colliders.push({ x1, z1, x2, z2, r: 0.35, low: true });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const S = new THREE.Matrix4().makeScale(-1, 1, 1);
|
|
513
|
+
const byFile = new Map();
|
|
514
|
+
const floorUnder = (x, z) => { // world → building-local is the INVERSE rotation — see the town builder
|
|
515
|
+
for (const f of floors) {
|
|
516
|
+
const dx = x - f.x, dz = z - f.z;
|
|
517
|
+
const c = Math.cos(f.ry), sn = Math.sin(f.ry);
|
|
518
|
+
const lx = c * dx - sn * dz, lz = sn * dx + c * dz;
|
|
519
|
+
if (Math.abs(lx) < f.hx && Math.abs(lz) < f.hz) return f.y;
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
};
|
|
523
|
+
// A HAND TOOL'S ORIGIN IS ITS GRIP, NOT ITS FOOT — the class of prop KC.airborneOk names.
|
|
524
|
+
// (Measured off Synty farm GLBs: a rake spans y −1.365…+0.029 — authored to be HELD, so the
|
|
525
|
+
// whole tool hangs below its origin. Planted as a ground prop it is buried to the last
|
|
526
|
+
// centimetre of its handle; lifted to seat its head on the dirt it asks for dy ≈ 1.3, which
|
|
527
|
+
// the drop rule slams straight back down. KC.airborneOk is exactly the declaration this
|
|
528
|
+
// needs: the authored height is deliberate.)
|
|
529
|
+
|
|
530
|
+
// ── WHOSE ZERO IS IT? THE LAYOUT DECLARES ITS DATUM ────────────────────────────────────────
|
|
531
|
+
// default → m[13] is height above the pack's floor plane, which sits KC.groundOff
|
|
532
|
+
// above the dirt. Lift it, snap it, seat it. Hand-authored areas are written
|
|
533
|
+
// to this contract.
|
|
534
|
+
// datum 'ground' → m[13] IS the height above the dirt. No lift, and no floor conversion
|
|
535
|
+
// either: a pot on a windowsill is already authored at windowsill height.
|
|
536
|
+
// (A parser that re-datums every matrix onto the area's own ground authors a
|
|
537
|
+
// chair in the dirt at m[13] ≈ 0; add the lift and it stands KC.groundOff up —
|
|
538
|
+
// too far to snap, not far enough to be dropped. It lands DEAD IN THE BAND
|
|
539
|
+
// and floats. Measured: 130 props off the ground across four areas, one dead-
|
|
540
|
+
// uniform constant added where it didn't belong.)
|
|
541
|
+
const groundDatum = area.datum === 'ground';
|
|
542
|
+
const LIFT = groundDatum ? 0 : KC.groundOff;
|
|
543
|
+
|
|
544
|
+
// ── THE DROP RULE IS FOR DRESSING, NOT FOR LANDFORM ────────────────────────────────────────
|
|
545
|
+
// A prop authored 2 m up with nothing under it is a mistake — in the source demo it stood on
|
|
546
|
+
// scenery we do not ship — so we drop it on the dirt. A PIT WALL authored 8 m up is not a
|
|
547
|
+
// mistake: it is the pit. Running the drop rule over a self-supporting 3D set piece makes A
|
|
548
|
+
// PANCAKE (measured: 218 of 293 pieces slammed flat, the worst falling 13.89 m — a quarry
|
|
549
|
+
// with no hole in it).
|
|
550
|
+
//
|
|
551
|
+
// WHICH PIECES ARE THE GROUND IS THE LAYOUT'S BUSINESS, NOT A REGEX'S. The layout marks them
|
|
552
|
+
// (`land: 1`): the pack's own rock and pit geometry. Two consequences, and they are the whole fix:
|
|
553
|
+
// * landform KEEPS ITS AUTHORED HEIGHT — never snapped, never dropped, never seated on a floor.
|
|
554
|
+
// It is the ground; there is nothing to seat it on.
|
|
555
|
+
// * landform BEARS WEIGHT — it is what `overLandform` looks for, so a barrel on a bench or a
|
|
556
|
+
// rail on a trestle is SUPPORTED and keeps its height too, instead of raining onto the pad.
|
|
557
|
+
// Everything else — tunnels, tramway, works, planks, dressing — stands on it and is judged by
|
|
558
|
+
// that support test, which is why none of them needs a flag of its own.
|
|
559
|
+
const land = (area.props ?? []).filter((q) => q.land);
|
|
560
|
+
// Each landform piece covers a disc of radius `r` (its real footprint, from the extractor —
|
|
561
|
+
// not a guess) and holds up anything within 6 m above it.
|
|
562
|
+
const overLandform = (p) => land.some((q) => {
|
|
563
|
+
const dx = p.m[12] - q.m[12], dz = p.m[14] - q.m[14];
|
|
564
|
+
return dx * dx + dz * dz < Math.pow((q.r ?? 2) + 1.5, 2) && q.m[13] + 0.5 >= p.m[13] - 6;
|
|
565
|
+
});
|
|
566
|
+
let cut = 0;
|
|
567
|
+
const ladderInst = []; // /Ladder/i props in this area → climbable records after the loop (appended to the town's set)
|
|
568
|
+
for (const p of area.props ?? []) {
|
|
569
|
+
if (at.skip?.(p)) continue; // pieces that needed the demo's own scenery
|
|
570
|
+
// A PATH WITH NOTHING UNDER IT IS NOT A PATH. Where the landform ends, the demo's ground
|
|
571
|
+
// carried on and ours does not — a raised path piece (KC.pathRe) survives only if landform
|
|
572
|
+
// actually sits beneath it. The ones that survive stay UP ON IT, which is what a tramway is.
|
|
573
|
+
// A cart on the ground beats a cart hanging over a deleted trestle.
|
|
574
|
+
if (KC.pathRe.test(p.f) && p.m[13] > 1.0 && !overLandform(p)) { cut++; continue; }
|
|
575
|
+
const m = new THREE.Matrix4().fromArray(p.m).premultiply(R); // area-local → world heading
|
|
576
|
+
const [px, pz] = toWorld(p.m[12], p.m[14]);
|
|
577
|
+
if (KC.propDelete.has(propKey(px, pz))) continue;
|
|
578
|
+
m.elements[12] = px; m.elements[14] = pz;
|
|
579
|
+
const g = KC.heightAt(px, pz);
|
|
580
|
+
if (p.land) {
|
|
581
|
+
m.elements[13] = g + p.m[13]; // THE GROUND ITSELF: exactly as authored, and that is all
|
|
582
|
+
} else {
|
|
583
|
+
let dy = p.m[13] + LIFT;
|
|
584
|
+
// "WHEELS RIDE THE DIRT" IS A FLOOR-DATUM RULE. It exists because a building's floor box
|
|
585
|
+
// reaches out past its walls, and a coach drawn up against a wall got lifted onto the
|
|
586
|
+
// floorboards. Under a ground datum nothing is ever lifted onto a floor, so the rule has
|
|
587
|
+
// no work to do — and it did harm: ore carts authored ON THE TRESTLE got dragged off it
|
|
588
|
+
// into the dirt. (Every other wheeled prop measured sat within 7 cm of the ground, so
|
|
589
|
+
// they snap to it anyway.)
|
|
590
|
+
const ground = Math.abs(dy) < KC.snap || (!groundDatum && KC.areaGroundOnly.test(p.f));
|
|
591
|
+
if (ground) dy = 0;
|
|
592
|
+
// Same rule as the town: only low porch dressing rides a floor; a prop authored high sat
|
|
593
|
+
// on scenery we don't ship and drops to the dirt.
|
|
594
|
+
// ...UNLESS THE AREA SAYS ITS FLOORS ARE TALL. A station platform's deck is 0.878 above
|
|
595
|
+
// the building origin, and every bench/barrel/lamp on it is authored at that height
|
|
596
|
+
// EXPECTING the floor conversion — a 0.5 cutoff sends the whole platform's dressing to
|
|
597
|
+
// the dirt underneath (an empty deck over a furnished crawlspace). `floorSeat` is the
|
|
598
|
+
// area's own declared ceiling for floor-seated dressing; areas that don't declare one
|
|
599
|
+
// keep the old rule.
|
|
600
|
+
const floor = (ground || p.m[13] > (area.floorSeat ?? 0.5)) ? null : floorUnder(px, pz);
|
|
601
|
+
// Is there ANYTHING under this — a building, or the landform it was set down on?
|
|
602
|
+
const supported = floor !== null || overLandform(p);
|
|
603
|
+
// On a ground-datum layout the floor test no longer CONVERTS anything (dy is already the
|
|
604
|
+
// true height above the dirt) — it survives as a VETO on the drop rule: there IS something
|
|
605
|
+
// under this, so leave it where the artist put it.
|
|
606
|
+
if (floor !== null && !groundDatum) m.elements[13] = floor + p.m[13];
|
|
607
|
+
else m.elements[13] = (dy > 0.5 && !supported && !KC.airborneOk.test(p.f)) ? g : dy + g; // nothing under it → the dirt
|
|
608
|
+
}
|
|
609
|
+
if (KC.propGround.has(propKey(px, pz))) m.elements[13] = g; // explicit: flat on the dirt
|
|
610
|
+
if (/Ladder/i.test(p.f)) ladderInst.push({ m: m.clone(), file: p.f, dir: resolveDir(p) }); // climbable — gathered BEFORE the mirror-flip below (a flipped basis would corrupt the climb quat)
|
|
611
|
+
const url = `${resolveDir(p)}/${p.f}`;
|
|
612
|
+
const mirrored = det3(m.elements) < 0;
|
|
613
|
+
if (mirrored) m.multiply(S);
|
|
614
|
+
const key = `${url}|${mirrored}`;
|
|
615
|
+
if (!byFile.has(key)) byFile.set(key, { url, tex: resolveTex(p), mirrored, mats: [] });
|
|
616
|
+
byFile.get(key).mats.push(m);
|
|
617
|
+
addFenceCapsule(p.f, m, p.r);
|
|
618
|
+
if (/Lantern|Campfire|Torch/i.test(p.f)) lampPositions.push({ x: px, y: m.elements[13] + 1.1, z: pz, ground: KC.heightAt(px, pz) });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
for (const { url, tex, mirrored, mats } of byFile.values()) {
|
|
622
|
+
try {
|
|
623
|
+
const master = await loadModel(`${url}.fbx`, { texture: tex });
|
|
624
|
+
const flat = flattenToGeometry(master);
|
|
625
|
+
if (!flat) continue;
|
|
626
|
+
const geo = mirrored ? bake(flat.geometry, S) : flat.geometry;
|
|
627
|
+
const mesh = new THREE.InstancedMesh(geo, await townMaterial(tex, `${url}.fbx`), mats.length);
|
|
628
|
+
mats.forEach((m, i) => mesh.setMatrixAt(i, m));
|
|
629
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
630
|
+
mesh.castShadow = mats.length < 4;
|
|
631
|
+
mesh.receiveShadow = true;
|
|
632
|
+
const file = url.split('/').pop();
|
|
633
|
+
mesh.name = `${KC.noCollide.test(file) ? 'scatterDeco' : 'scatter'}:${file}.fbx`;
|
|
634
|
+
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
|
|
635
|
+
scene.add(mesh);
|
|
636
|
+
} catch (e) { console.warn(`[outpost] prop batch ${url} failed`, e); }
|
|
637
|
+
}
|
|
638
|
+
ladders.push(...await buildLadders(ladderInst)); // APPEND (the town builder already reset + filled the town's; areas add to them)
|
|
639
|
+
console.log(`[outpost] ${area.name}: ${built} buildings, ${(area.props ?? []).length} props, ${ladderInst.length} ladder(s) at (${at.x}, ${at.z})`);
|
|
640
|
+
return { lampPositions };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// THE TOWN STAMPER (world splits 5d) — stamps the game's authored settlement (KC.town):
|
|
644
|
+
// merged buildings on the road-clearance guard-rail, fence capsule runs, the full prop
|
|
645
|
+
// seating law (interior floors → snap band → floor claim → drop rule), the EXTRAS hand
|
|
646
|
+
// list, and the editable town-centre map. Returns { lampPositions, doors, ladders }.
|
|
647
|
+
export async function buildTown(scene) {
|
|
648
|
+
const T = KC.town;
|
|
649
|
+
if (!T) { console.warn('[town] no KC.town registered — nothing to stamp'); return { lampPositions: [], doors, ladders }; }
|
|
650
|
+
const lampPositions = [];
|
|
651
|
+
// Every building's FLOOR PLANE, for grounding its dressing (see the props loop): a building
|
|
652
|
+
// is seated ONCE at its centre's terrain height, but each prop used to be seated on the
|
|
653
|
+
// terrain under ITSELF — so on any slope the furniture drifted off the floor it stands on
|
|
654
|
+
// (the saloon's till floated 0.27m over the bar). Interior props ride the building instead.
|
|
655
|
+
const floors = []; // { x, z, r2, y }
|
|
656
|
+
|
|
657
|
+
// ---- buildings ---------------------------------------------------------------
|
|
658
|
+
for (const b of T.layout) {
|
|
659
|
+
const pieces = T.pieces[b.id] ?? [];
|
|
660
|
+
if (!pieces.length) { console.warn(`[town] no pieces for ${b.id}`); continue; }
|
|
661
|
+
const { geo, key, doorways, interiors } = await buildingGeometry(b.id, pieces);
|
|
662
|
+
if (!geo) continue;
|
|
663
|
+
|
|
664
|
+
// Shove any building whose footprint straddles a road off the carriageway, along the
|
|
665
|
+
// road-distance gradient. A generator that guarantees every building clears
|
|
666
|
+
// KC.roadDistance by collider+2.5m never fires this — it stays as the guard-rail for
|
|
667
|
+
// hand edits.
|
|
668
|
+
let bx = b.x, bz = b.z;
|
|
669
|
+
const clearance = (b.collider ?? 7) + 2.5;
|
|
670
|
+
for (let i = 0; i < 6 && KC.roadDistance(bx, bz) < clearance; i++) {
|
|
671
|
+
const d = KC.roadDistance(bx, bz);
|
|
672
|
+
let gx = KC.roadDistance(bx + 0.5, bz) - KC.roadDistance(bx - 0.5, bz);
|
|
673
|
+
let gz = KC.roadDistance(bx, bz + 0.5) - KC.roadDistance(bx, bz - 0.5);
|
|
674
|
+
let gl = Math.hypot(gx, gz);
|
|
675
|
+
if (gl < 0.05) { gx = bx || 1; gz = bz || 1; gl = Math.hypot(gx, gz) || 1; } // dead on centreline → push radially out
|
|
676
|
+
const step = Math.max(1, clearance - d);
|
|
677
|
+
bx += (gx / gl) * step; bz += (gz / gl) * step;
|
|
678
|
+
if (i === 0) console.warn(`[town] nudging ${b.name} off the road (${d.toFixed(1)}m < ${clearance.toFixed(1)}m)`);
|
|
679
|
+
}
|
|
680
|
+
// seat on the LOWEST ground point under the footprint, never floating — but never
|
|
681
|
+
// more than 0.8m below the centre either (a sample in a hollow would bury
|
|
682
|
+
// the whole building; the "black slabs")
|
|
683
|
+
let y = KC.heightAt(bx, bz);
|
|
684
|
+
const r = (b.collider ?? 6) * 0.7;
|
|
685
|
+
for (const [dx, dz] of [[r, 0], [-r, 0], [0, r], [0, -r], [r * 0.7, r * 0.7], [-r * 0.7, -r * 0.7]]) {
|
|
686
|
+
y = Math.min(y, KC.heightAt(bx + dx, bz + dz));
|
|
687
|
+
}
|
|
688
|
+
y = Math.max(y, KC.heightAt(bx, bz) - 0.8);
|
|
689
|
+
|
|
690
|
+
// each plot draws its own paint (recolour atlases etc.) through the game's hook
|
|
691
|
+
const tex = T.buildingTex ? T.buildingTex(b, pieces) : resolveTex(pieces[0]);
|
|
692
|
+
const mat = await townMaterial(tex, `${resolveDir(pieces[0])}/${pieces[0].f}.fbx`);
|
|
693
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
694
|
+
// sit the building on its own ground plane (see KC.groundOff): the floor/boardwalk rides
|
|
695
|
+
// proud of the street, its skirt beds into the dirt, and the deck steps land on the street
|
|
696
|
+
const floorY = y + buildingLift(geo);
|
|
697
|
+
mesh.position.set(bx, floorY, bz);
|
|
698
|
+
mesh.rotation.y = b.ry;
|
|
699
|
+
mesh.castShadow = true;
|
|
700
|
+
mesh.receiveShadow = true;
|
|
701
|
+
mesh.name = `bld:${b.id}`;
|
|
702
|
+
scene.add(mesh);
|
|
703
|
+
// A TENT DOES NOT STOP A MAN, AND ITS PEG CERTAINLY DOESN'T. A soft shell's guy-ropes and
|
|
704
|
+
// pegs are part of the one welded mesh, so putting it in the collision BVH snags the player
|
|
705
|
+
// at ankle height — you walk into a tent and get stuck on the rope on the way out. It is
|
|
706
|
+
// soft canvas: walk through it. (KC.town.softShell names them; a teepee's hide is stiffer
|
|
707
|
+
// and its poles are a real obstacle, so those keep their collision.)
|
|
708
|
+
const noCollide = T.softShell && pieces.some((pc) => T.softShell.test(pc.f));
|
|
709
|
+
if (!noCollide) buildingObjects.push(mesh); // collision is the capsule-vs-BVH sweep, not a 2D shape
|
|
710
|
+
// building-local y=0 IS the floor plane — that is the datum its dressing must stand on.
|
|
711
|
+
// FOOTPRINT = THE BUILDING'S REAL BOX, not a circle round its centre. `collider` is a
|
|
712
|
+
// road-clearance radius, and it is far smaller than the building: a circle that size
|
|
713
|
+
// leaves every CORNER of every interior outside it, so the furniture standing there got
|
|
714
|
+
// seated on the terrain instead of on the floorboards — and floated (or sank) by the
|
|
715
|
+
// ground's slope. Take the box from the merged geometry, which is the actual thing we
|
|
716
|
+
// just built.
|
|
717
|
+
geo.computeBoundingBox();
|
|
718
|
+
const bb = geo.boundingBox;
|
|
719
|
+
floors.push({
|
|
720
|
+
x: bx, z: bz, y: floorY, ry: b.ry ?? 0,
|
|
721
|
+
hx: Math.max(bb.max.x, -bb.min.x) + 0.4, // half-extents in BUILDING-LOCAL space
|
|
722
|
+
hz: Math.max(bb.max.z, -bb.min.z) + 0.4,
|
|
723
|
+
interiors, // the SHELL pieces' own boxes — the rooms
|
|
724
|
+
});
|
|
725
|
+
for (const d of makeDoors(mesh, doorways, floorY, mat, key)) {
|
|
726
|
+
d.name = b.name;
|
|
727
|
+
doors.push(d);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ---- fences: capsules, because the BVH won't take them -------------------------
|
|
732
|
+
// world.build EXCLUDEs /fence/ meshes from the mesh BVH (they must stay jumpable), so
|
|
733
|
+
// fence panels get one low capsule each — endpoints derived from each panel's real box.
|
|
734
|
+
for (const r of T.fenceRuns ?? []) colliders.push({ ...r, r: 0.35, low: true });
|
|
735
|
+
|
|
736
|
+
// ---- props: ONE InstancedMesh per file, full per-instance matrices --------------
|
|
737
|
+
// The layout's matrices carry rotation AND scale AND tilt (a leaning barrel, a tipped
|
|
738
|
+
// cart), which an x/y/z/ry-only scatter path cannot express — so props are stamped
|
|
739
|
+
// as instanced batches. Stored m[13] is height ABOVE GROUND; heightAt adds the terrain.
|
|
740
|
+
// A MIRRORED instance can't share a batch: winding is per draw call, not per instance. So a
|
|
741
|
+
// file with mirrored placements gets a SECOND batch built from a pre-mirrored geometry
|
|
742
|
+
// (X negated, winding flipped) with the mirror factored out of each instance matrix:
|
|
743
|
+
// M = M' · S, S = diag(-1,1,1) ⇒ M'·(S·v) = M·v exactly, and det(M') > 0.
|
|
744
|
+
const S = new THREE.Matrix4().makeScale(-1, 1, 1);
|
|
745
|
+
const byFile = new Map(); // `${dir}/${f}|mirror` -> { url, tex, mirrored, mats[] }
|
|
746
|
+
const add = (p, mat) => {
|
|
747
|
+
const url = `${resolveDir(p)}/${p.f}`;
|
|
748
|
+
const mirrored = det3(mat.elements) < 0;
|
|
749
|
+
if (mirrored) mat.multiply(S); // M' = M · S (positive determinant)
|
|
750
|
+
const key = `${url}|${mirrored}`;
|
|
751
|
+
if (!byFile.has(key)) byFile.set(key, { url, tex: resolveTex(p), mirrored, mats: [] });
|
|
752
|
+
byFile.get(key).mats.push(mat);
|
|
753
|
+
};
|
|
754
|
+
// Stored m[13] is the prop's height in the SOURCE's frame — same datum as the buildings, so
|
|
755
|
+
// it gets the same KC.groundOff lift (an interior chair at 0 lands on the floor, a porch
|
|
756
|
+
// barrel on the boardwalk). KC.snap: source ground dressing scatters in a band below zero;
|
|
757
|
+
// anything that lands within snap of our dirt IS a ground prop and is planted exactly on it
|
|
758
|
+
// — no floating troughs, no half-sunk fences.
|
|
759
|
+
// A prop that did NOT snap to the dirt is standing on something the BUILDING owns — its
|
|
760
|
+
// floor, its bar top, its upstairs. Those must be seated on the building's floor plane, not
|
|
761
|
+
// on the terrain under the prop: the two differ by the ground's slope across the footprint,
|
|
762
|
+
// and that difference is exactly the gap the till hovered over the bar (measured: 0.27m).
|
|
763
|
+
// ...tested in the BUILDING'S OWN FRAME: rotate the prop into it and check the box.
|
|
764
|
+
// WORLD → LOCAL IS THE INVERSE ROTATION, and the inverse of makeRotationY(ry) — which maps
|
|
765
|
+
// local (x,z) to world (cos·x + sin·z, −sin·x + cos·z) — is that same matrix TRANSPOSED:
|
|
766
|
+
// lx = cos(ry)·dx − sin(ry)·dz, lz = sin(ry)·dx + cos(ry)·dz
|
|
767
|
+
// Negating the angle as well applies the FORWARD rotation instead, i.e. it tests the prop
|
|
768
|
+
// against the box's mirror image. It cancels out at ry = 0 and ±π/2 (a symmetric box cannot
|
|
769
|
+
// tell a point from its negation) — and it is wrong at every other angle.
|
|
770
|
+
const floorUnder = (x, z) => {
|
|
771
|
+
for (const f of floors) {
|
|
772
|
+
const dx = x - f.x, dz = z - f.z;
|
|
773
|
+
const c = Math.cos(f.ry), s = Math.sin(f.ry);
|
|
774
|
+
const lx = c * dx - s * dz, lz = s * dx + c * dz;
|
|
775
|
+
if (Math.abs(lx) < f.hx && Math.abs(lz) < f.hz) return f.y;
|
|
776
|
+
}
|
|
777
|
+
return null;
|
|
778
|
+
};
|
|
779
|
+
// ...but WITHIN THE WALLS the floor is not up for debate. A parser that anchors each plot at
|
|
780
|
+
// the source's y=0 keeps the source's own bedding — whole plots sunk into sculpted sand — so
|
|
781
|
+
// a plot's INTERIOR dressing can be stored slightly NEGATIVE. The snap test reads that band
|
|
782
|
+
// as "ground dressing" — a datum the bedded plots never shared — and plants the props on the
|
|
783
|
+
// dirt, below the floorboards they stand dead-centre on: a barrel stack waist-deep in the
|
|
784
|
+
// floor. So a prop inside a SHELL piece's own box (the room, not the eaves-padded plot box
|
|
785
|
+
// floorUnder checks) belongs to that building's floor plane before the snap test, before
|
|
786
|
+
// groundOnly, before everything: stored y above local 0 is kept (the upstairs stays
|
|
787
|
+
// upstairs), stored y below it is the plot's bedding and is clamped to the boards. Outside
|
|
788
|
+
// the walls nothing here fires and the rules below hold unchanged.
|
|
789
|
+
const interiorUnder = (x, z) => {
|
|
790
|
+
for (const f of floors) {
|
|
791
|
+
const dx = x - f.x, dz = z - f.z;
|
|
792
|
+
const c = Math.cos(f.ry), s = Math.sin(f.ry);
|
|
793
|
+
const lx = c * dx - s * dz, lz = s * dx + c * dz;
|
|
794
|
+
if (Math.abs(lx) >= f.hx || Math.abs(lz) >= f.hz) continue; // cheap reject: outside the plot box
|
|
795
|
+
for (const b of f.interiors) {
|
|
796
|
+
if (lx > b.x0 && lx < b.x1 && lz > b.z0 && lz < b.z1) return f.y;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return null;
|
|
800
|
+
};
|
|
801
|
+
// Props that HANG or MOUNT are allowed to sit in mid-air with nothing under them — that is
|
|
802
|
+
// what a sign on a post, a lantern on a bracket or a curtain on its rail IS. Everything else
|
|
803
|
+
// is furniture, and furniture needs a floor. (T.airborneOk)
|
|
804
|
+
// ...and things with WHEELS belong on the dirt, whatever the footprint test says. A building's
|
|
805
|
+
// floor datum is claimed by its whole BOX, which reaches out past the walls under the eaves —
|
|
806
|
+
// so a stagecoach drawn up against the wall got lifted onto the floorboards and stood there
|
|
807
|
+
// hovering. Wheels stay on the ground. (T.groundOnly)
|
|
808
|
+
// T.outdoorOnly = groundOnly minus the decorative wheel: OUTDOOR furniture that must NEVER
|
|
809
|
+
// be lifted onto a building's floor, even when the interior box over-reaches to the wall's
|
|
810
|
+
// edge and claims it. A `Wheel` is exempt: a cartwheel leaning on an inside wall genuinely
|
|
811
|
+
// stands on the boards.
|
|
812
|
+
const AIRBORNE_OK = T.airborneOk ?? /$^/;
|
|
813
|
+
const GROUND_ONLY = T.groundOnly ?? /$^/;
|
|
814
|
+
const OUTDOOR_ONLY = T.outdoorOnly ?? /$^/;
|
|
815
|
+
const ladderInst = []; // /Ladder/i props gathered here; resolved to world climb records after the meshes load
|
|
816
|
+
ladders.length = 0;
|
|
817
|
+
for (const p of T.dressing) {
|
|
818
|
+
if (KC.propDelete.has(propKey(p.m[12], p.m[14]))) continue; // source junk flagged for the bin
|
|
819
|
+
const m = new THREE.Matrix4().fromArray(p.m);
|
|
820
|
+
const inside = OUTDOOR_ONLY.test(p.f) ? null : interiorUnder(p.m[12], p.m[14]); // outdoor furniture never rides a floor
|
|
821
|
+
if (inside !== null) {
|
|
822
|
+
// Inside a shell: on that building's floor plane, bedding clamped (see interiorUnder).
|
|
823
|
+
// Even a wheel — a cartwheel leaning on the inside wall stands on the boards;
|
|
824
|
+
// groundOnly exists for the overreaching plot box, and the walls don't overreach.
|
|
825
|
+
m.elements[13] = inside + Math.max(p.m[13], 0);
|
|
826
|
+
} else {
|
|
827
|
+
let dy = p.m[13] + KC.groundOff;
|
|
828
|
+
const onWheels = GROUND_ONLY.test(p.f);
|
|
829
|
+
const ground = Math.abs(dy) < KC.snap || onWheels;
|
|
830
|
+
if (ground) dy = 0;
|
|
831
|
+
// A building's floor only claims REAL porch dressing — a prop authored near y=0 that stands
|
|
832
|
+
// on the boardwalk. A prop the source authored HIGH (p.m[13] > 0.5) sat on furniture or
|
|
833
|
+
// scenery we don't ship; it is not floor dressing, so it drops to the dirt like any
|
|
834
|
+
// unsupported prop. This is the whole street of floating crates, beds, sacks, coffins
|
|
835
|
+
// and desks at once.
|
|
836
|
+
const floor = (ground || p.m[13] > 0.5) ? null : floorUnder(p.m[12], p.m[14]);
|
|
837
|
+
if (floor !== null) {
|
|
838
|
+
m.elements[13] = floor + p.m[13];
|
|
839
|
+
} else {
|
|
840
|
+
const g = KC.heightAt(p.m[12], p.m[14]);
|
|
841
|
+
m.elements[13] = (dy > 0.5 && !AIRBORNE_OK.test(p.f)) ? g : dy + g;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (KC.propGround.has(propKey(p.m[12], p.m[14]))) m.elements[13] = KC.heightAt(p.m[12], p.m[14]); // explicit: flat on the dirt
|
|
845
|
+
add(p, m);
|
|
846
|
+
if (/Ladder/i.test(p.f)) ladderInst.push({ m: m.clone(), file: p.f, dir: resolveDir(p) }); // climbable — resolved after the meshes load
|
|
847
|
+
addFenceCapsule(p.f, m, p.r);
|
|
848
|
+
if (/Lantern/i.test(p.f)) lampPositions.push({ x: p.m[12], y: m.elements[13] + 1.1, z: p.m[14], ground: KC.heightAt(p.m[12], p.m[14]) });
|
|
849
|
+
}
|
|
850
|
+
for (const p of T.extras ?? []) {
|
|
851
|
+
const y = KC.heightAt(p.x, p.z);
|
|
852
|
+
add(p, new THREE.Matrix4().compose(
|
|
853
|
+
new THREE.Vector3(p.x, y + (p.y ?? 0), p.z), // `y`: sit it ON something (a lamp on its post)
|
|
854
|
+
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), p.ry),
|
|
855
|
+
new THREE.Vector3(1, p.sy ?? 1, 1), // `sy`: stretch a fence post into a lamp post
|
|
856
|
+
));
|
|
857
|
+
// the glow belongs at the FLAME, which is now up on the post — not down in the dirt
|
|
858
|
+
if (p.lamp) lampPositions.push({ x: p.x, y: y + (p.y ?? 0) + 0.15, z: p.z, ground: y });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
for (const { url, tex, mirrored, mats } of byFile.values()) {
|
|
862
|
+
try {
|
|
863
|
+
const master = await loadModel(`${url}.fbx`, { texture: tex });
|
|
864
|
+
const flat = flattenToGeometry(master);
|
|
865
|
+
if (!flat) continue;
|
|
866
|
+
const geo = mirrored ? bake(flat.geometry, S) : flat.geometry;
|
|
867
|
+
const mesh = new THREE.InstancedMesh(geo, await townMaterial(tex, `${url}.fbx`), mats.length);
|
|
868
|
+
mats.forEach((m, i) => mesh.setMatrixAt(i, m));
|
|
869
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
870
|
+
mesh.castShadow = mats.length < 4;
|
|
871
|
+
mesh.receiveShadow = true;
|
|
872
|
+
// `scatter:` → world.build bakes it into the collision BVH (its own EXCLUDE regex also
|
|
873
|
+
// drops fence/grass/ground names — which is exactly why fenceRuns exists above).
|
|
874
|
+
// `scatterDeco:` → visual only.
|
|
875
|
+
const file = url.split('/').pop();
|
|
876
|
+
mesh.name = `${KC.noCollide.test(file) ? 'scatterDeco' : 'scatter'}:${file}.fbx`;
|
|
877
|
+
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
|
|
878
|
+
scene.add(mesh);
|
|
879
|
+
} catch (e) { console.warn(`[town] prop batch ${url} failed`, e); }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
ladders.push(...await buildLadders(ladderInst));
|
|
883
|
+
await dressTownCenter(scene);
|
|
884
|
+
console.log(`[town] ${doors.length} working door(s) · ${ladders.length} climbable ladder(s)`);
|
|
885
|
+
return { lampPositions, doors, ladders };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Town-centre: render the editable town-map objects (KC.town.map, authored in the game's
|
|
889
|
+
// map editor). One instanced mesh per (file, collider) group. Stored Y is relative to the
|
|
890
|
+
// flat town pad; we add the pad height (sampled at KC.town.mapAnchor) here. Objects flagged
|
|
891
|
+
// as colliders are named `scatterCol:` so world.build bakes them into the collision BVH;
|
|
892
|
+
// the rest (`scatterDeco:`) are visual. The path stays live for the map editor even when
|
|
893
|
+
// the map ships empty.
|
|
894
|
+
async function dressTownCenter(scene) {
|
|
895
|
+
const T = KC.town;
|
|
896
|
+
if (!T?.map?.length) return;
|
|
897
|
+
const a = T.mapAnchor ?? { x: 0, z: 0 };
|
|
898
|
+
const baseY = KC.heightAt(a.x, a.z);
|
|
899
|
+
const groups = new Map(); // `${file}|${collide}` -> { file, collide, mats[] }
|
|
900
|
+
for (const o of T.map) {
|
|
901
|
+
const key = `${o.f}|${o.c}`;
|
|
902
|
+
if (!groups.has(key)) groups.set(key, { file: o.f, collide: o.c, mats: [] });
|
|
903
|
+
const m = new THREE.Matrix4().fromArray(o.m);
|
|
904
|
+
m.elements[13] += baseY; // stored Y is relative to the pad
|
|
905
|
+
groups.get(key).mats.push(m);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
for (const { file, collide, mats } of groups.values()) {
|
|
909
|
+
try {
|
|
910
|
+
const url = `${KC.dirs[KC.defaultDir]}/${file}.fbx`;
|
|
911
|
+
const master = await loadModel(url, { texture: KC.atlases[KC.defaultDir] });
|
|
912
|
+
const flat = flattenToGeometry(master);
|
|
913
|
+
if (!flat) continue;
|
|
914
|
+
const mesh = new THREE.InstancedMesh(flat.geometry, await townMaterial(KC.atlases[KC.defaultDir], url), mats.length);
|
|
915
|
+
mats.forEach((m, i) => mesh.setMatrixAt(i, m));
|
|
916
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
917
|
+
mesh.castShadow = !!collide;
|
|
918
|
+
mesh.receiveShadow = true;
|
|
919
|
+
mesh.frustumCulled = false;
|
|
920
|
+
mesh.name = `${collide ? 'scatterCol' : 'scatterDeco'}:${file}.fbx`;
|
|
921
|
+
scene.add(mesh);
|
|
922
|
+
} catch (e) { console.warn(`map ${file} failed`, e); }
|
|
923
|
+
}
|
|
924
|
+
}
|