sindicate 0.13.0 → 0.16.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 +118 -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/systems/train.js +16 -6
- package/src/vehicle/car.js +321 -0
- package/src/vehicle/carAudio.js +124 -0
- 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/roadAssert.js +125 -0
- package/src/world/roadKit.js +110 -0
- package/src/world/roadLink.js +343 -0
- package/src/world/roadNetwork.js +158 -0
- package/src/world/roadTerrain.js +377 -0
- package/src/world/roadWorld.js +200 -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/unityPackageExtract.mjs +56 -0
- package/tools/unityPrefabToAssembly.mjs +276 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// THE WATER MESHES (world splits, step 6) — river ribbons, lake sheets and the sea plane,
|
|
2
|
+
// moved verbatim from western's zones.js. All geometry + per-vertex hydraulic bakes; the
|
|
3
|
+
// MATERIALS stay the game's choice (one shared waterMaterial across lakes, one shared
|
|
4
|
+
// riverMaterial across courses — each distinct refracting material pays its own viewport
|
|
5
|
+
// copy per frame, so sharing collapses the copies however many bodies the county gains).
|
|
6
|
+
import * as THREE from 'three/webgpu';
|
|
7
|
+
|
|
8
|
+
// A river ribbon: walk the path in ~2m steps, emit left/right cross-section verts at ±w,
|
|
9
|
+
// bake the still-water depth (level − bed) + downstream UVs per vertex, strip the quads.
|
|
10
|
+
// Emitted as ~200 m CHUNKS (each its own geometry + bounding sphere) rather than one
|
|
11
|
+
// course-long mesh: a 3.6 km course has a county-sized bounding sphere, so frustumCulled
|
|
12
|
+
// never actually culled, and refraction pays a viewport copy per DRAW. Boundary
|
|
13
|
+
// cross-section rows are duplicated into both neighbouring chunks — identical baked values
|
|
14
|
+
// from identical inputs, so the strip has no seam.
|
|
15
|
+
export function buildRiverMesh(scene, R, mat, { heightAt }) {
|
|
16
|
+
const L = THREE.MathUtils.lerp, pts = [];
|
|
17
|
+
// Reference half-width for the flow bake: the course's own source→mouth hydraulic ramp,
|
|
18
|
+
// PER STATION — not one course-wide constant. The solved widths are that ramp ±25 %
|
|
19
|
+
// pool/riffle modulation, and it is the MODULATION continuity should answer to: against a
|
|
20
|
+
// constant reference, a narrow upstream pool computes faster than a wide downstream riffle
|
|
21
|
+
// (measured: pools averaged 0.50 m/s to the riffles' 0.44), which is backwards. Against
|
|
22
|
+
// the local ramp, wide-for-here runs slow and narrow-for-here runs quick, the whole course over.
|
|
23
|
+
const wEnd = R.pts.length - 1 || 1;
|
|
24
|
+
const wrefAt = (f) => L(R.wsrc ?? R.w, R.w, f);
|
|
25
|
+
for (let i = 0; i < R.pts.length - 1; i++) {
|
|
26
|
+
const a = R.pts[i], b = R.pts[i + 1], steps = Math.max(1, Math.round(Math.hypot(b.x - a.x, b.z - a.z) / 2));
|
|
27
|
+
for (let s = 0; s < steps; s++) { const t = s / steps; pts.push({ x: L(a.x, b.x, t), z: L(a.z, b.z, t), wy: L(R.wy[i], R.wy[i + 1], t), ww: L(R.ww[i], R.ww[i + 1], t), wr: wrefAt((i + t) / wEnd) }); }
|
|
28
|
+
}
|
|
29
|
+
const e = R.pts.length - 1; pts.push({ x: R.pts[e].x, z: R.pts[e].z, wy: R.wy[e], ww: R.ww[e], wr: wrefAt(1) });
|
|
30
|
+
|
|
31
|
+
// cross-section resolution scales with the river's width — ~2.4m across (odd → centreline)
|
|
32
|
+
const K = Math.max(9, Math.min(41, 2 * Math.round(Math.max(...R.ww) / 2.4) + 1));
|
|
33
|
+
const CHUNK_LEN = 200; // m of course per geometry
|
|
34
|
+
const meshes = [];
|
|
35
|
+
const newChunk = () => ({ verts: [], depths: [], uvs: [], flows: [], drops: [], bWy: [], bX: [], bZ: [], rows: 0 });
|
|
36
|
+
const pushRow = (c, row) => {
|
|
37
|
+
for (const [dst, src] of [[c.verts, row.verts], [c.depths, row.depths], [c.uvs, row.uvs],
|
|
38
|
+
[c.flows, row.flows], [c.drops, row.drops], [c.bWy, row.bWy], [c.bX, row.bX], [c.bZ, row.bZ]]) dst.push(...src);
|
|
39
|
+
c.rows++;
|
|
40
|
+
};
|
|
41
|
+
const flush = (c) => {
|
|
42
|
+
const idx = [];
|
|
43
|
+
for (let i = 0; i < c.rows - 1; i++) for (let k = 0; k < K - 1; k++) {
|
|
44
|
+
const a = i * K + k, b = a + 1, q = (i + 1) * K + k, d = q + 1;
|
|
45
|
+
idx.push(a, q, b, b, q, d);
|
|
46
|
+
}
|
|
47
|
+
const geo = new THREE.BufferGeometry();
|
|
48
|
+
geo.setAttribute('position', new THREE.Float32BufferAttribute(c.verts, 3));
|
|
49
|
+
geo.setAttribute('aDepth', new THREE.Float32BufferAttribute(c.depths, 1));
|
|
50
|
+
geo.setAttribute('uv', new THREE.Float32BufferAttribute(c.uvs, 2));
|
|
51
|
+
geo.setAttribute('aFlow', new THREE.Float32BufferAttribute(c.flows, 2));
|
|
52
|
+
geo.setAttribute('aDrop', new THREE.Float32BufferAttribute(c.drops, 1));
|
|
53
|
+
geo.setIndex(idx);
|
|
54
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
55
|
+
// per-chunk bake data — refreshRiverDepths already iterates N meshes, so each chunk
|
|
56
|
+
// re-derives its own aDepth after the terrain pads mutate heightAt
|
|
57
|
+
mesh.userData.riverVerts = { wy: Float32Array.from(c.bWy), x: Float32Array.from(c.bX), z: Float32Array.from(c.bZ) };
|
|
58
|
+
geo.computeBoundingSphere();
|
|
59
|
+
mesh.frustumCulled = true; // refraction pays a viewport copy per DRAW — never draw off-screen chunks
|
|
60
|
+
scene.add(mesh);
|
|
61
|
+
meshes.push(mesh);
|
|
62
|
+
};
|
|
63
|
+
let cur = newChunk(), chunkStart = 0, along = 0;
|
|
64
|
+
for (let i = 0; i < pts.length; i++) {
|
|
65
|
+
const p = pts[i], pa = pts[Math.max(0, i - 1)], pb = pts[Math.min(pts.length - 1, i + 1)];
|
|
66
|
+
let tx = pb.x - pa.x, tz = pb.z - pa.z; const tl = Math.hypot(tx, tz) || 1; tx /= tl; tz /= tl;
|
|
67
|
+
const nx = -tz, nz = tx; // perpendicular in the XZ plane
|
|
68
|
+
if (i > 0) along += Math.hypot(p.x - pts[i - 1].x, p.z - pts[i - 1].z);
|
|
69
|
+
// rapids strength: how fast the water level DROPS just downstream of here (per metre)
|
|
70
|
+
const pd = pts[Math.min(pts.length - 1, i + 3)];
|
|
71
|
+
const dDist = Math.hypot(pd.x - p.x, pd.z - p.z) || 1;
|
|
72
|
+
const drop = Math.max(0, (p.wy - pd.wy) / dDist);
|
|
73
|
+
const row = newChunk(); // one cross-section, built once…
|
|
74
|
+
for (let k = 0; k < K; k++) {
|
|
75
|
+
const side = (k / (K - 1)) * 2 - 1; // -1 (left bank) … 0 (centre) … 1 (right bank)
|
|
76
|
+
const vx = p.x + nx * p.ww * side, vz = p.z + nz * p.ww * side;
|
|
77
|
+
const g = heightAt(vx, vz);
|
|
78
|
+
// wet verts sit at water level; DRY verts dive a fixed metre BELOW the surface, so the
|
|
79
|
+
// sheet submerges and the waterline is the smooth surface/terrain intersection
|
|
80
|
+
row.verts.push(vx, g < p.wy ? p.wy : p.wy - 1.0, vz);
|
|
81
|
+
row.depths.push(p.wy - g); // ≤0 on dry verts → transparent + bank foam at the line
|
|
82
|
+
row.bWy.push(p.wy); row.bX.push(vx); row.bZ.push(vz);
|
|
83
|
+
row.uvs.push((side + 1) * 0.5, along * 0.1); // V runs down the WHOLE course — seamless across chunks
|
|
84
|
+
// downstream velocity (m/s), BAKED FROM THE HYDRAULICS instead of a universal
|
|
85
|
+
// constant (every reach of every course at the same speed — a pool as fast as a
|
|
86
|
+
// rapid). Slope sets the pace (drop is already per-metre, from the reach-baked wy
|
|
87
|
+
// three nodes downstream), width modulates it by continuity against the local ramp
|
|
88
|
+
// (wr above): the solved courses run ±25 % pool/riffle width, so a narrow riffle
|
|
89
|
+
// visibly quickens and a wide pool slows to a crawl.
|
|
90
|
+
const v = THREE.MathUtils.clamp(0.4 + 10 * drop, 0.3, 2.4) * (p.wr / p.ww);
|
|
91
|
+
row.flows.push(tx * v, tz * v);
|
|
92
|
+
row.drops.push(drop);
|
|
93
|
+
}
|
|
94
|
+
pushRow(cur, row);
|
|
95
|
+
if (along - chunkStart >= CHUNK_LEN && i < pts.length - 1) {
|
|
96
|
+
flush(cur);
|
|
97
|
+
cur = newChunk(); chunkStart = along;
|
|
98
|
+
pushRow(cur, row); // …and shared by both sides of the cut
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
flush(cur);
|
|
102
|
+
return meshes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A lake sheet: one plane per body at the survey level − drop, per-vertex aDepth bake, dry
|
|
106
|
+
// verts dived below the surface, per-vertex aRClip so ONE shared material keeps per-lake
|
|
107
|
+
// sprawl clip. The shared material is the game's (waterMaterial({ rClip: 'attribute' })).
|
|
108
|
+
export function buildLake(scene, Lk, mat, { heightAt, drop = 0.8 }) {
|
|
109
|
+
const rClip = Lk.rClip ?? Lk.r * 0.85;
|
|
110
|
+
const wr = rClip + 4;
|
|
111
|
+
const wseg = THREE.MathUtils.clamp(Math.round((wr * 2) / 1.8), 16, 64); // ~1.8m facets
|
|
112
|
+
const wgeo = new THREE.PlaneGeometry(wr * 2, wr * 2, wseg, wseg);
|
|
113
|
+
const waterY = Lk.level - drop;
|
|
114
|
+
const dp = wgeo.attributes.position, aDepth = new Float32Array(dp.count);
|
|
115
|
+
const aRClip = new Float32Array(dp.count).fill(rClip); // per-vertex so the shared shader keeps per-lake sprawl clip
|
|
116
|
+
for (let i = 0; i < dp.count; i++) {
|
|
117
|
+
const g = heightAt(Lk.x + dp.getX(i), Lk.z - dp.getY(i));
|
|
118
|
+
aDepth[i] = waterY - g;
|
|
119
|
+
if (g >= waterY) dp.setZ(i, -drop); // dry verts dive below the surface (plane local +z is world up)
|
|
120
|
+
}
|
|
121
|
+
wgeo.setAttribute('aDepth', new THREE.BufferAttribute(aDepth, 1));
|
|
122
|
+
wgeo.setAttribute('aRClip', new THREE.BufferAttribute(aRClip, 1));
|
|
123
|
+
const lake = new THREE.Mesh(wgeo, mat);
|
|
124
|
+
lake.rotation.x = -Math.PI / 2;
|
|
125
|
+
lake.position.set(Lk.x, waterY, Lk.z);
|
|
126
|
+
scene.add(lake);
|
|
127
|
+
return lake;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The sea: a flat ocean plane at seaLevel, shown only where land dips below it past coastZ.
|
|
131
|
+
export function buildSea(scene, mat, { worldSize, seaLevel, coastZ, heightAt }) {
|
|
132
|
+
const SEG = 256;
|
|
133
|
+
const geo = new THREE.PlaneGeometry(worldSize, worldSize, SEG, SEG);
|
|
134
|
+
const pos = geo.attributes.position, aDepth = new Float32Array(pos.count);
|
|
135
|
+
for (let i = 0; i < pos.count; i++) {
|
|
136
|
+
const X = pos.getX(i), Z = -pos.getY(i);
|
|
137
|
+
aDepth[i] = Z > coastZ ? Math.max(0, seaLevel - heightAt(X, Z)) : 0;
|
|
138
|
+
}
|
|
139
|
+
geo.setAttribute('aDepth', new THREE.BufferAttribute(aDepth, 1));
|
|
140
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
141
|
+
mesh.rotation.x = -Math.PI / 2;
|
|
142
|
+
mesh.position.y = seaLevel;
|
|
143
|
+
mesh.frustumCulled = false;
|
|
144
|
+
scene.add(mesh);
|
|
145
|
+
return mesh;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Rivers v2: re-derive every river vert's depth from the CURRENT terrain — pads that
|
|
149
|
+
// register during world.build mutate heightAt AFTER the ribbons bake, leaving stale aDepth.
|
|
150
|
+
// Called at the END of world.build; re-runnable whenever terrain changes.
|
|
151
|
+
export function refreshRiverDepths(waters, { heightAt }) {
|
|
152
|
+
let fixed = 0;
|
|
153
|
+
for (const m of waters ?? []) {
|
|
154
|
+
const rb = m.userData?.riverVerts;
|
|
155
|
+
if (!rb) continue;
|
|
156
|
+
const pos = m.geometry.attributes.position, aD = m.geometry.attributes.aDepth;
|
|
157
|
+
for (let i = 0; i < aD.count; i++) {
|
|
158
|
+
const g = heightAt(rb.x[i], rb.z[i]);
|
|
159
|
+
const d = rb.wy[i] - g;
|
|
160
|
+
if (Math.abs(d - aD.getX(i)) > 0.02) fixed++;
|
|
161
|
+
aD.setX(i, d);
|
|
162
|
+
pos.setY(i, g < rb.wy[i] ? rb.wy[i] : rb.wy[i] - 1.0);
|
|
163
|
+
}
|
|
164
|
+
aD.needsUpdate = true; pos.needsUpdate = true;
|
|
165
|
+
m.geometry.computeBoundingSphere();
|
|
166
|
+
}
|
|
167
|
+
if (fixed) console.log(`[rivers] depth refresh: ${fixed} verts re-derived after terrain pads`);
|
|
168
|
+
}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// THE WORLD FACADE's ENGINE HALF (world splits, step 7) — the capsule character
|
|
2
|
+
// controller, the 2D collider walk, the BVH oracles (floors, sight, shots), the dev
|
|
3
|
+
// probes and the region streamer, moved verbatim from western's world.js (whose body was
|
|
4
|
+
// duplicated near line-for-line in fable). A game subclasses this with its build()
|
|
5
|
+
// recipe, its update() loop, its zoneName() and its overrides of the HOOKS below:
|
|
6
|
+
//
|
|
7
|
+
// groundAt(x, z) terrain + any analytic lifts (western adds walk-humps,
|
|
8
|
+
// fable branches into its dungeon)
|
|
9
|
+
// _colliderList(x, z) the 2D collider array collide() walks (fable swaps to its
|
|
10
|
+
// dungeon set inside the barrow); default: the kit registry
|
|
11
|
+
// _sweepBVHs(x, z, sweep) every BVH the capsule depenetrates against
|
|
12
|
+
// _moveBVHs(pos) the BVHs the ground snap + floor oracles raycast
|
|
13
|
+
// _shotBVHs() every BVH that stops sight/shots (movement's set + pieces)
|
|
14
|
+
// _losBVHs(from) per-query override of the shot set (fable's dungeon)
|
|
15
|
+
// _terrainCatchable(x, z) veto the heightfield ground catch (fable's Ironkeep mask
|
|
16
|
+
// hides the snow skin inside the halls — fall to the MESH)
|
|
17
|
+
// _vlogExtra(pos) extra columns for the __vdebug flight recorder
|
|
18
|
+
import * as THREE from 'three/webgpu';
|
|
19
|
+
import { colliders, doorsAt, buildingObjects } from './kit.js';
|
|
20
|
+
import { buildBVHAsync, colliderWireframe } from './collision.js';
|
|
21
|
+
import { getLODColliderGroups } from './scatter.js';
|
|
22
|
+
import { dist2D } from '../core/utils.js';
|
|
23
|
+
|
|
24
|
+
export class WorldBase {
|
|
25
|
+
constructor(scene, { heightAt = () => 0, waterSurfaceAt = null } = {}) {
|
|
26
|
+
this.scene = scene;
|
|
27
|
+
this._heightAt = heightAt;
|
|
28
|
+
this._waterSurfaceAt = waterSurfaceAt;
|
|
29
|
+
this.sky = null;
|
|
30
|
+
this.weather = null;
|
|
31
|
+
this.lamps = [];
|
|
32
|
+
this.water = null;
|
|
33
|
+
this.grass = null;
|
|
34
|
+
this.footprints = null;
|
|
35
|
+
this.tiles = null;
|
|
36
|
+
this.bridges = [];
|
|
37
|
+
this.time = 0;
|
|
38
|
+
this.buildingBVH = null;
|
|
39
|
+
this.sideBVHs = [];
|
|
40
|
+
this.deferredRegions = [];
|
|
41
|
+
this._capSeg = new THREE.Line3();
|
|
42
|
+
this._capBox = new THREE.Box3();
|
|
43
|
+
this._triPt = new THREE.Vector3();
|
|
44
|
+
this._capPt = new THREE.Vector3();
|
|
45
|
+
this._pushDir = new THREE.Vector3();
|
|
46
|
+
this._snapRay = new THREE.Ray();
|
|
47
|
+
this._losRay = new THREE.Ray();
|
|
48
|
+
this._losDir = new THREE.Vector3();
|
|
49
|
+
this._bodySeg = new THREE.Line3();
|
|
50
|
+
this._bodyBox = new THREE.Box3();
|
|
51
|
+
this._bodyTri = new THREE.Vector3();
|
|
52
|
+
this._bodyPt = new THREE.Vector3();
|
|
53
|
+
this._bodyDir = new THREE.Vector3();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── the hooks (see the header) ──
|
|
57
|
+
groundAt(x, z) { return this._heightAt(x, z); }
|
|
58
|
+
_colliderList(x, z) { return colliders; }
|
|
59
|
+
_sweepBVHs(x, z, sweep) {
|
|
60
|
+
sweep(this.buildingBVH);
|
|
61
|
+
for (const b of this.sideBVHs ?? []) sweep(b);
|
|
62
|
+
this.tiles?.forEachBVHNear(x, z, sweep);
|
|
63
|
+
}
|
|
64
|
+
_moveBVHs(pos) { return [this.buildingBVH, ...(this.sideBVHs ?? [])]; }
|
|
65
|
+
_shotBVHs() { return [this.buildingBVH, ...(this.sideBVHs ?? [])]; }
|
|
66
|
+
_losBVHs(from) { return this._shotBVHs(); }
|
|
67
|
+
_terrainCatchable(x, z) { return true; }
|
|
68
|
+
_vlogExtra(pos) { return []; }
|
|
69
|
+
_regionObjects() { return buildingObjects; } // the array deferred-region builds push into
|
|
70
|
+
|
|
71
|
+
// The rendered water surface Y at (x,z), or null on dry land. Single source of truth
|
|
72
|
+
// shared with the water meshes — so depth/wade and the visible surface can never
|
|
73
|
+
// disagree (the recurring bug).
|
|
74
|
+
waterSurfaceAt(x, z) {
|
|
75
|
+
return this._waterSurfaceAt ? this._waterSurfaceAt(x, z) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// How deep standing water is at (x,z): 0 on dry land, else surface − ground. Derived
|
|
79
|
+
// from the ONE surface fn, so its footprint == the rendered water footprint (no
|
|
80
|
+
// phantom wading / walk-underwater).
|
|
81
|
+
waterDepthAt(x, z) {
|
|
82
|
+
const s = this.waterSurfaceAt(x, z);
|
|
83
|
+
return s === null ? 0 : Math.max(0, s - this._heightAt(x, z));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// THE SURFACE AN NPC STANDS ON at (x,z): the terrain (groundAt), raised to a building floor or a
|
|
87
|
+
// boardwalk deck when one is REALLY over this exact column. It is a straight-down BVH ray — the same
|
|
88
|
+
// real plank/plinth geometry the player's capsule reads — with NO capsule radius, so a plinth LIP
|
|
89
|
+
// beside the feet can never catch and lift him (that was the hovering-people bug). Off the deck the
|
|
90
|
+
// ray finds nothing and he sits on the dirt; a pace onto it, he stands on the boards.
|
|
91
|
+
//
|
|
92
|
+
// A footprint BOX cannot do this job: a building's box is the whole rectangle (walls, roof-eaves,
|
|
93
|
+
// an unmodelled interior), so it claims "floor" over open ground a man is only standing BESIDE —
|
|
94
|
+
// hovering him — and, inset to stop that, buries the man standing in the inset ring on a deck that
|
|
95
|
+
// really is under him. The geometry itself is the only honest oracle, so we ask it.
|
|
96
|
+
//
|
|
97
|
+
// STRICTLY NPC SEATING (post/spawn/settle, traveller & encounter seating) — all at boot /
|
|
98
|
+
// spawn, never per frame. The PLAYER must not read this: he already rides real plinths through the
|
|
99
|
+
// per-frame capsule sweep, and groundAt and every player-capsule path are left exactly as they were.
|
|
100
|
+
// Cast starts a storey above the ground and ignores anything higher (a roof) or wall-steep (a wall).
|
|
101
|
+
walkSurfaceAt(x, z) {
|
|
102
|
+
const g = this.groundAt(x, z);
|
|
103
|
+
const CEIL = 1.4; // a floor a man could step onto; above it is roof
|
|
104
|
+
let best = g;
|
|
105
|
+
const ray = this._snapRay;
|
|
106
|
+
ray.origin.set(x, g + CEIL, z);
|
|
107
|
+
ray.direction.set(0, -1, 0);
|
|
108
|
+
for (const bvh of this._moveBVHs({ x, z })) {
|
|
109
|
+
if (!bvh) continue;
|
|
110
|
+
const hits = bvh.raycast ? bvh.raycast(ray, THREE.DoubleSide)
|
|
111
|
+
: (() => { const h = bvh.raycastFirst?.(ray, THREE.DoubleSide); return h ? [h] : []; })();
|
|
112
|
+
for (const h of hits) {
|
|
113
|
+
const y = h.point.y;
|
|
114
|
+
if (y < g - 0.05 || y > g + CEIL + 1e-3 || y <= best) continue;
|
|
115
|
+
const n = h.face?.normal;
|
|
116
|
+
if (n && Math.abs(n.y) < 0.5) continue; // a wall, not a floor
|
|
117
|
+
best = y;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Like walkSurfaceAt but returns the LOWEST floor above the ground — the surface you actually WALK on —
|
|
124
|
+
// rather than the highest. NAV bake uses this: walkSurfaceAt returns a TABLETOP (the highest horizontal
|
|
125
|
+
// surface over a floor), which made the bake think a walker stands ON the tables (tables read walkable,
|
|
126
|
+
// and the floor↔tabletop jump at every table edge speckled the whole interior). Sampling the FLOOR
|
|
127
|
+
// instead means the bake's furniture ray (which starts a body-height ABOVE the sample) catches the
|
|
128
|
+
// tabletops/counters sitting on that floor and blocks their cells — clean open lanes between solid tables.
|
|
129
|
+
navFloorAt(x, z) {
|
|
130
|
+
const g = this.groundAt(x, z);
|
|
131
|
+
const CEIL = 1.4;
|
|
132
|
+
let best = null;
|
|
133
|
+
const ray = this._snapRay;
|
|
134
|
+
ray.origin.set(x, g + CEIL, z);
|
|
135
|
+
ray.direction.set(0, -1, 0);
|
|
136
|
+
for (const bvh of this._moveBVHs({ x, z })) {
|
|
137
|
+
if (!bvh) continue;
|
|
138
|
+
const hits = bvh.raycast ? bvh.raycast(ray, THREE.DoubleSide)
|
|
139
|
+
: (() => { const h = bvh.raycastFirst?.(ray, THREE.DoubleSide); return h ? [h] : []; })();
|
|
140
|
+
for (const h of hits) {
|
|
141
|
+
const y = h.point.y;
|
|
142
|
+
if (y < g - 0.05 || y > g + CEIL + 1e-3) continue;
|
|
143
|
+
const n = h.face?.normal;
|
|
144
|
+
if (n && Math.abs(n.y) < 0.5) continue; // a wall, not a floor
|
|
145
|
+
if (best === null || y < best) best = y; // LOWEST floor above the ground (a real floor, not a tabletop above it)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return best === null ? g : best;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// true if a wall is between `from` and `to` (tested at chest height) — blocks melee/shots through walls
|
|
152
|
+
losBlocked(from, to) {
|
|
153
|
+
return this._rayBlocked(this._losBVHs(from), from.x, from.y + 1.0, from.z, to.x, to.y + 1.0, to.z, -0.4);
|
|
154
|
+
}
|
|
155
|
+
_rayBlocked(bvhs, fx, fy, fz, tx, ty, tz, slack) {
|
|
156
|
+
this._losDir.set(tx - fx, ty - fy, tz - fz);
|
|
157
|
+
const dist = this._losDir.length();
|
|
158
|
+
if (dist < 0.01) return false;
|
|
159
|
+
this._losDir.divideScalar(dist);
|
|
160
|
+
for (const bvh of bvhs) {
|
|
161
|
+
if (!bvh?.raycastFirst) continue;
|
|
162
|
+
this._losRay.origin.set(fx, fy, fz);
|
|
163
|
+
this._losRay.direction.copy(this._losDir);
|
|
164
|
+
const hit = bvh.raycastFirst(this._losRay, THREE.DoubleSide);
|
|
165
|
+
if (hit && hit.distance <= dist + slack) return true;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// true if world geometry (building/rock/scatter BVH) crosses the RAW segment from→to.
|
|
171
|
+
// Projectiles: test each frame's movement step so fast shots can't tunnel through a
|
|
172
|
+
// thin wall between frames. Real 3D — arcing over a roof correctly misses.
|
|
173
|
+
segmentBlocked(from, to) {
|
|
174
|
+
return this._rayBlocked(this._losBVHs(from), from.x, from.y, from.z, to.x, to.y, to.z, 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Push a point out of the static colliders the BVH does NOT own: fence
|
|
178
|
+
// capsules (jumpable) and trees (circles). Buildings, rocks and props are
|
|
179
|
+
// handled by the capsule-vs-BVH sweep in moveCapsule.
|
|
180
|
+
// Shapes: oriented box ({hx,hz,cs,sn}), capsule along a segment (x1..z2/r), circle ({x,z,r}).
|
|
181
|
+
// `skip` is the collider list of a vehicle the entity is RIDING: a man on a
|
|
182
|
+
// coach roof must not be pushed about by the coach's own box. Short array, or null.
|
|
183
|
+
collide(x, z, radius = 0.45, airClear = 0, y, skip = null) {
|
|
184
|
+
for (const c of this._colliderList(x, z)) {
|
|
185
|
+
if (skip && skip.includes(c)) continue;
|
|
186
|
+
if (c.low && airClear > 0.6) continue; // fences: cleared by a jump
|
|
187
|
+
// height-banded colliders (multi-storey: an upstairs balcony must not block the
|
|
188
|
+
// street below it). No band = always solid.
|
|
189
|
+
if (c.y0 !== undefined && y !== undefined && (y > c.y1 || y + 1.8 < c.y0)) continue;
|
|
190
|
+
if (c.hx !== undefined) {
|
|
191
|
+
if (c.hx <= 0) continue; // opened doorway: hx=0 must mean OFF — the +radius inflation
|
|
192
|
+
// otherwise leaves an invisible radius-wide pillar at the
|
|
193
|
+
// centre of EVERY open door (the mid-doorway blockade)
|
|
194
|
+
// oriented box: resolve in the box's local frame, push out the nearest face
|
|
195
|
+
const dx = x - c.x, dz = z - c.z;
|
|
196
|
+
let lx = c.cs * dx - c.sn * dz;
|
|
197
|
+
let lz = c.sn * dx + c.cs * dz;
|
|
198
|
+
const ex = c.hx + radius, ez = c.hz + radius;
|
|
199
|
+
if (Math.abs(lx) < ex && Math.abs(lz) < ez) {
|
|
200
|
+
if (ex - Math.abs(lx) < ez - Math.abs(lz)) lx = lx < 0 ? -ex : ex;
|
|
201
|
+
else lz = lz < 0 ? -ez : ez;
|
|
202
|
+
x = c.x + c.cs * lx + c.sn * lz;
|
|
203
|
+
z = c.z - c.sn * lx + c.cs * lz;
|
|
204
|
+
}
|
|
205
|
+
} else if (c.x1 !== undefined) {
|
|
206
|
+
// capsule: closest point on the segment, then push out radially
|
|
207
|
+
const vx = c.x2 - c.x1, vz = c.z2 - c.z1;
|
|
208
|
+
const len2 = vx * vx + vz * vz || 1;
|
|
209
|
+
let t = ((x - c.x1) * vx + (z - c.z1) * vz) / len2;
|
|
210
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
211
|
+
const px = c.x1 + vx * t, pz = c.z1 + vz * t;
|
|
212
|
+
const d = dist2D(x, z, px, pz);
|
|
213
|
+
const min = c.r + radius;
|
|
214
|
+
if (d < min && d > 0.0001) {
|
|
215
|
+
const push = (min - d) / d;
|
|
216
|
+
x += (x - px) * push;
|
|
217
|
+
z += (z - pz) * push;
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const d = dist2D(x, z, c.x, c.z);
|
|
221
|
+
const min = c.r + radius;
|
|
222
|
+
if (d < min && d > 0.0001) {
|
|
223
|
+
const push = (min - d) / d;
|
|
224
|
+
x += (x - c.x) * push;
|
|
225
|
+
z += (z - c.z) * push;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// THE DOORS. A shut one is a thin oriented slab across its opening; an open one is a hole
|
|
230
|
+
// (toggleDoor sets hx = 0). They are NOT in `colliders`: there are 130-odd, they never move, and
|
|
231
|
+
// this function is called 200-400 times a frame — so they are bucketed into an 8 m grid (kit.js)
|
|
232
|
+
// and only the one or two under your feet are ever tested.
|
|
233
|
+
const near = doorsAt(x, z);
|
|
234
|
+
for (let i = 0; i < near.length; i++) {
|
|
235
|
+
const c = near[i];
|
|
236
|
+
if (c.hx <= 0) continue; // opened doorway: no invisible pillar in it
|
|
237
|
+
if (y !== undefined && (y > c.y1 || y + 1.8 < c.y0)) continue; // the leaf's own band: upstairs doors
|
|
238
|
+
const dx = x - c.x, dz = z - c.z;
|
|
239
|
+
let lx = c.cs * dx - c.sn * dz;
|
|
240
|
+
let lz = c.sn * dx + c.cs * dz;
|
|
241
|
+
const ex = c.hx + radius, ez = c.hz + radius;
|
|
242
|
+
if (Math.abs(lx) < ex && Math.abs(lz) < ez) {
|
|
243
|
+
if (ex - Math.abs(lx) < ez - Math.abs(lz)) lx = lx < 0 ? -ex : ex;
|
|
244
|
+
else lz = lz < 0 ? -ez : ez;
|
|
245
|
+
x = c.x + c.cs * lx + c.sn * lz;
|
|
246
|
+
z = c.z - c.sn * lx + c.cs * lz;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { x, z };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// HOW FAR A BODY HAS TO MOVE TO BE OUT OF THE SOLID WORLD, horizontally, at (x,z), standing on
|
|
253
|
+
// ground y. The player's controller already does this and then walks on (moveCapsule); a VEHICLE
|
|
254
|
+
// wants the vector back, because four tons that has hit a wall must lose its speed as well as its
|
|
255
|
+
// ground, and only the caller knows which way it was going.
|
|
256
|
+
//
|
|
257
|
+
// Both halves of the world, because the player is stopped by both and a coach that consulted only
|
|
258
|
+
// one would drive through the other: the props/fences/trees/vehicles LIST (collide, above) and the
|
|
259
|
+
// BUILDINGS, which live in the BVH and in nothing else — which is exactly why a driven coach used
|
|
260
|
+
// to park in the middle of the station.
|
|
261
|
+
//
|
|
262
|
+
// Only WALLS. A contact whose normal points mostly up is a floor, a porch deck or a boardwalk, and
|
|
263
|
+
// a vehicle does not climb: she is on the dirt, her height comes from the heightfield, and a push
|
|
264
|
+
// out of a floor she is standing near would only shove her sideways for no reason.
|
|
265
|
+
solidPush(x, y, z, r, out, skip = null) {
|
|
266
|
+
const c = this.collide(x, z, r, 0, y, skip);
|
|
267
|
+
const seg = this._bodySeg, box = this._bodyBox;
|
|
268
|
+
// her body, as a vertical stick: clear of the dirt (so the ground itself is never a contact) up
|
|
269
|
+
// to the top of her box — the same band her own collider carries.
|
|
270
|
+
seg.start.set(c.x, y + 0.35, c.z);
|
|
271
|
+
seg.end.set(c.x, y + 1.70, c.z);
|
|
272
|
+
const sweep = (bvh) => {
|
|
273
|
+
if (!bvh) return;
|
|
274
|
+
box.makeEmpty();
|
|
275
|
+
box.expandByPoint(seg.start);
|
|
276
|
+
box.expandByPoint(seg.end);
|
|
277
|
+
box.min.addScalar(-r);
|
|
278
|
+
box.max.addScalar(r);
|
|
279
|
+
bvh.shapecast({
|
|
280
|
+
intersectsBounds: (b) => b.intersectsBox(box),
|
|
281
|
+
intersectsTriangle: (tri) => {
|
|
282
|
+
const d = tri.closestPointToSegment(seg, this._bodyTri, this._bodyPt);
|
|
283
|
+
if (d >= r) return;
|
|
284
|
+
this._bodyDir.copy(this._bodyPt).sub(this._bodyTri);
|
|
285
|
+
const len = this._bodyDir.length();
|
|
286
|
+
if (len < 1e-6) return;
|
|
287
|
+
this._bodyDir.multiplyScalar(1 / len);
|
|
288
|
+
if (Math.abs(this._bodyDir.y) > 0.6) return; // a floor or a roof, not a wall
|
|
289
|
+
const depth = r - d;
|
|
290
|
+
const px = this._bodyDir.x * depth, pz = this._bodyDir.z * depth;
|
|
291
|
+
seg.start.x += px; seg.start.z += pz;
|
|
292
|
+
seg.end.x += px; seg.end.z += pz;
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
this._sweepBVHs(seg.start.x, seg.start.z, sweep);
|
|
297
|
+
return out.set(seg.start.x - x, 0, seg.start.z - z);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Capsule character controller (bvh mode). Substeps the move to avoid
|
|
301
|
+
// tunneling through stairs, depenetrating a vertical capsule against the
|
|
302
|
+
// building BVH each substep, with the terrain heightfield as the ground floor
|
|
303
|
+
// and the legacy 2D pass still handling props/fences/trees (buildings skipped).
|
|
304
|
+
// Mutates entity.position / entity.vy / entity.airborne. Returns grounded.
|
|
305
|
+
moveCapsule(entity, hx, hz, dt) {
|
|
306
|
+
const steps = entity.capsuleSteps ?? 5;
|
|
307
|
+
let grounded = false;
|
|
308
|
+
for (let s = 0; s < steps; s++) {
|
|
309
|
+
if (this._capsuleStep(entity, hx / steps, hz / steps, dt / steps)) grounded = true;
|
|
310
|
+
}
|
|
311
|
+
const pos = entity.position;
|
|
312
|
+
const radius = entity.capsuleR ?? entity.radius ?? 0.4;
|
|
313
|
+
|
|
314
|
+
// snap-to-ground: if we were grounded and aren't moving up, stay glued to a
|
|
315
|
+
// surface within one step below — stops falling through open stair treads or
|
|
316
|
+
// off the nose of a downslope. Jumps (vy>0) and real ledge drops (>snap) skip it.
|
|
317
|
+
if (!grounded && entity._capGrounded && entity.vy <= 0) {
|
|
318
|
+
const snap = 0.55;
|
|
319
|
+
const terrainY = this.groundAt(pos.x, pos.z);
|
|
320
|
+
let target = (pos.y - terrainY >= 0 && pos.y - terrainY < snap) ? terrainY : -Infinity;
|
|
321
|
+
for (const bvh of this._moveBVHs(pos)) {
|
|
322
|
+
if (!bvh) continue;
|
|
323
|
+
this._snapRay.origin.set(pos.x, pos.y + 0.1, pos.z);
|
|
324
|
+
this._snapRay.direction.set(0, -1, 0);
|
|
325
|
+
const hit = bvh.raycastFirst(this._snapRay, THREE.DoubleSide);
|
|
326
|
+
if (hit && hit.point.y <= pos.y + 0.1 && pos.y - hit.point.y < snap && hit.point.y > target) target = hit.point.y; // glue to porch floors/stairs, not just terrain
|
|
327
|
+
}
|
|
328
|
+
if (target > -Infinity) { pos.y = target; grounded = true; entity.vy = 0; }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// props / fences / trees on the legacy 2D pass, once per frame
|
|
332
|
+
// (buildings are owned by the BVH so they're skipped here)
|
|
333
|
+
const c = this.collide(pos.x, pos.z, radius, pos.y - this.groundAt(pos.x, pos.z), pos.y, entity._skipCols);
|
|
334
|
+
pos.x = c.x; pos.z = c.z;
|
|
335
|
+
|
|
336
|
+
entity._capGrounded = grounded;
|
|
337
|
+
entity.airborne = !grounded;
|
|
338
|
+
if (typeof window !== 'undefined' && window.__vdebug && entity.game && entity.game.player === entity) {
|
|
339
|
+
(window.__vlog ??= []).push([+pos.y.toFixed(3), grounded ? 1 : 0, +this.groundAt(pos.x, pos.z).toFixed(3), +(entity.vy ?? 0).toFixed(2), ...this._vlogExtra(pos)]);
|
|
340
|
+
if (window.__vlog.length > 600) window.__vlog.shift();
|
|
341
|
+
}
|
|
342
|
+
return grounded;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_capsuleStep(entity, hx, hz, dt) {
|
|
346
|
+
const pos = entity.position;
|
|
347
|
+
const radius = entity.capsuleR ?? entity.radius ?? 0.4;
|
|
348
|
+
const height = entity.capsuleH ?? 1.7;
|
|
349
|
+
entity.vy = (entity.vy ?? 0) - 20 * dt;
|
|
350
|
+
let nx = pos.x + hx, nz = pos.z + hz, ny = pos.y + entity.vy * dt;
|
|
351
|
+
let grounded = false;
|
|
352
|
+
|
|
353
|
+
const seg = this._capSeg, box = this._capBox;
|
|
354
|
+
seg.start.set(nx, ny + radius, nz);
|
|
355
|
+
seg.end.set(nx, ny + height - radius, nz);
|
|
356
|
+
const y0 = seg.start.y;
|
|
357
|
+
// Sweep the capsule against ONE bvh, accumulating depenetration into `seg`. The bounds box is
|
|
358
|
+
// recomputed each call since seg moves between casts. We sweep the boot BVH (buildings + non-tiled
|
|
359
|
+
// scatter) PLUS every loaded TileField BVH near the capsule, so streamed colliders block too.
|
|
360
|
+
const sweep = (bvh) => {
|
|
361
|
+
if (!bvh) return;
|
|
362
|
+
box.makeEmpty();
|
|
363
|
+
box.expandByPoint(seg.start);
|
|
364
|
+
box.expandByPoint(seg.end);
|
|
365
|
+
box.min.addScalar(-radius);
|
|
366
|
+
box.max.addScalar(radius);
|
|
367
|
+
bvh.shapecast({
|
|
368
|
+
intersectsBounds: (b) => b.intersectsBox(box),
|
|
369
|
+
intersectsTriangle: (tri) => {
|
|
370
|
+
const d = tri.closestPointToSegment(seg, this._triPt, this._capPt);
|
|
371
|
+
if (d < radius) {
|
|
372
|
+
const depth = radius - d;
|
|
373
|
+
this._pushDir.copy(this._capPt).sub(this._triPt);
|
|
374
|
+
const len = this._pushDir.length();
|
|
375
|
+
if (len > 1e-6) {
|
|
376
|
+
this._pushDir.multiplyScalar(1 / len);
|
|
377
|
+
if (this._pushDir.y > 0.6) {
|
|
378
|
+
// Walkable ground/slope (contact normal mostly up): resolve the
|
|
379
|
+
// penetration straight UP, not along the tilted normal. On bumpy
|
|
380
|
+
// stone the normal tilts, so a full-normal push has a horizontal
|
|
381
|
+
// component that drifts the capsule every frame — the character
|
|
382
|
+
// slides while standing still. Pushing up by depth/normal.y
|
|
383
|
+
// clears the overlap with zero horizontal motion (and still
|
|
384
|
+
// counts as grounded below). Walls (near-horizontal normal) fall
|
|
385
|
+
// through to the full push, so they block and you slide along.
|
|
386
|
+
const dy = depth / this._pushDir.y;
|
|
387
|
+
seg.start.y += dy;
|
|
388
|
+
seg.end.y += dy;
|
|
389
|
+
} else {
|
|
390
|
+
seg.start.addScaledVector(this._pushDir, depth);
|
|
391
|
+
seg.end.addScaledVector(this._pushDir, depth);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
this._sweepBVHs(nx, nz, sweep);
|
|
399
|
+
nx = seg.start.x;
|
|
400
|
+
nz = seg.start.z;
|
|
401
|
+
ny = seg.start.y - radius;
|
|
402
|
+
// a net upward push means we're standing on something (floor/stair/balcony)
|
|
403
|
+
if (seg.start.y - y0 > 1e-3 && entity.vy <= 0) { grounded = true; entity.vy = 0; }
|
|
404
|
+
|
|
405
|
+
// The terrain heightfield is the ground floor — BUT the capsule sweep above is
|
|
406
|
+
// authoritative: if it already grounded us on a MESH (building floor, stair),
|
|
407
|
+
// the heightfield must NOT yank us off it. Only catch us with the terrain when
|
|
408
|
+
// nothing solid is underfoot (and the game hasn't vetoed the catch — a hidden
|
|
409
|
+
// terrain skin inside a mesh-floored hall must not catch anyone).
|
|
410
|
+
const terrainY = this.groundAt(nx, nz);
|
|
411
|
+
if (ny <= terrainY && !grounded && this._terrainCatchable(nx, nz)) { ny = terrainY; if (entity.vy < 0) entity.vy = 0; grounded = true; }
|
|
412
|
+
|
|
413
|
+
pos.set(nx, ny, nz);
|
|
414
|
+
return grounded;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Dev probe: drop a capsule at (x,z) from `fromY`, settle a few frames, and
|
|
418
|
+
// report where it rests. Lets collision be checked numerically (game.__probe).
|
|
419
|
+
probe(x, z, fromY = 50) {
|
|
420
|
+
const dummy = { position: new THREE.Vector3(x, fromY, z), vy: 0, airborne: true, radius: 0.4 };
|
|
421
|
+
const tr = [];
|
|
422
|
+
for (let i = 0; i < 60; i++) { this.moveCapsule(dummy, 0, 0, 1 / 60); tr.push(+dummy.position.y.toFixed(3)); }
|
|
423
|
+
const last = tr.slice(-15);
|
|
424
|
+
return { x: dummy.position.x, y: +dummy.position.y.toFixed(3), z: dummy.position.z, grounded: !dummy.airborne, settle: +(Math.max(...last) - Math.min(...last)).toFixed(3), tail: tr.slice(-6) };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Dev: what actually got baked into the collision BVH.
|
|
428
|
+
colliderInfo() {
|
|
429
|
+
const EXCLUDE = /tree|bush|fence|flower|path|brick|ground|floor|grass/i;
|
|
430
|
+
const inc = [], exc = [];
|
|
431
|
+
for (const g of getLODColliderGroups()) (EXCLUDE.test(g.name) ? exc : inc).push(`${g.name} x${g.count}`);
|
|
432
|
+
const scatterMeshes = [];
|
|
433
|
+
this.scene.traverse((o) => {
|
|
434
|
+
if (o.isInstancedMesh && /^scatter:/.test(o.name || '') && !EXCLUDE.test(o.name)) {
|
|
435
|
+
scatterMeshes.push(`${o.name} (live x${o.count})`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
tris: this.buildingBVH ? Math.round(this.buildingBVH.geometry.attributes.position.count / 3) : 0,
|
|
440
|
+
lodBaked: inc, // distance-LOD groups in the collider (rocks)
|
|
441
|
+
lodSkipped: exc, // excluded (trees / roads / bushes)
|
|
442
|
+
plainScatter: scatterMeshes,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
showCollider(on = true) {
|
|
447
|
+
if (on && !this._colliderViz && this.buildingBVH) {
|
|
448
|
+
this._colliderViz = colliderWireframe(this.buildingBVH);
|
|
449
|
+
if (this._colliderViz) this.scene.add(this._colliderViz);
|
|
450
|
+
} else if (!on && this._colliderViz) {
|
|
451
|
+
this.scene.remove(this._colliderViz);
|
|
452
|
+
this._colliderViz = null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// REGION STREAMER (GTA loading): builds the regions the boot bubble skipped, in play.
|
|
457
|
+
// One region at a time, nearest-first. Two triggers: APPROACH (inside TRIGGER_R — the
|
|
458
|
+
// player is heading there, build now) and IDLE TRICKLE (one region every few seconds
|
|
459
|
+
// regardless, so the whole map quietly finishes in the first minutes). Builders await
|
|
460
|
+
// per-asset loads, so the work naturally yields; deferred buildings land AFTER the boot
|
|
461
|
+
// BVH bake, so their buildingObjects pushes are spliced into a worker-built side-BVH
|
|
462
|
+
// (swept by shots/snap/capsule via this.sideBVHs). Lamp positions join the lamp pool
|
|
463
|
+
// (append is safe — the pool re-sorts nightly; light COUNT never changes).
|
|
464
|
+
_updateRegionStreamer(playerPos) {
|
|
465
|
+
if (!this.deferredRegions?.length || this._regionBuilding) return;
|
|
466
|
+
// grace: let the opening seconds present smoothly before any background building
|
|
467
|
+
if (!this._streamT0) this._streamT0 = performance.now();
|
|
468
|
+
if (performance.now() - this._streamT0 < 8000) return;
|
|
469
|
+
const TRIGGER_R = 600, IDLE_MS = 5000;
|
|
470
|
+
let best = null, bestD = Infinity;
|
|
471
|
+
for (const r of this.deferredRegions) {
|
|
472
|
+
const d = Math.hypot(playerPos.x - r.x, playerPos.z - r.z);
|
|
473
|
+
if (d < bestD) { bestD = d; best = r; }
|
|
474
|
+
}
|
|
475
|
+
const idleDue = performance.now() > (this._lastRegionBuild ?? 0) + IDLE_MS;
|
|
476
|
+
if (!best || (bestD > TRIGGER_R && !idleDue)) return;
|
|
477
|
+
this._regionBuilding = true;
|
|
478
|
+
this._lastRegionBuild = performance.now();
|
|
479
|
+
this.deferredRegions.splice(this.deferredRegions.indexOf(best), 1);
|
|
480
|
+
const regionObjs = this._regionObjects();
|
|
481
|
+
const before = regionObjs.length;
|
|
482
|
+
best.build().then(async (res) => {
|
|
483
|
+
const newObjs = regionObjs.splice(before); // one build at a time — no interleaved pushes
|
|
484
|
+
if (res?.lampPositions?.length) this.lampPositions.push(...res.lampPositions.map((lp) => ({ x: lp.x, y: lp.y, z: lp.z })));
|
|
485
|
+
if (newObjs.length) {
|
|
486
|
+
try { const bvh = await buildBVHAsync(newObjs, []); if (bvh) this.sideBVHs.push(bvh); }
|
|
487
|
+
catch (e) { console.warn(`[region] ${best.name} side-BVH failed`, e); }
|
|
488
|
+
}
|
|
489
|
+
console.log(`[region] ${best.name} built in play (${newObjs.length} building colliders, ${Math.round(bestD)}m away, ${this.deferredRegions.length} to go)`);
|
|
490
|
+
}).catch((e) => console.warn(`[region] ${best.name} build failed`, e))
|
|
491
|
+
.finally(() => { this._regionBuilding = false; });
|
|
492
|
+
}
|
|
493
|
+
}
|