perf(city): optimize rendering for expanded city bounds
Tighten directional light shadow frustum to follow the player and batch static geometries into merged meshes, reducing draw calls significantly. Expand city world bounds to support 72 blocks and add per-frame performance monitoring instrumentation. BREAKING CHANGE: city bounds changed from ±80 to ±120/±160, update any hardcoded client-side world assumptions.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@@ -28,10 +28,13 @@ class CityWorldSeeder extends Seeder
|
|||||||
['slug' => 'city10'],
|
['slug' => 'city10'],
|
||||||
[
|
[
|
||||||
'name' => 'City10',
|
'name' => 'City10',
|
||||||
'bounds_min_x' => -80,
|
// Performance-tuned size: 72 blocks (9 × 8) after the
|
||||||
'bounds_max_x' => 80,
|
// 40-unit road grid and its outer margin are derived from
|
||||||
'bounds_min_z' => -80,
|
// these bounds (layout −160..200 × −160..160).
|
||||||
'bounds_max_z' => 80,
|
'bounds_min_x' => -120,
|
||||||
|
'bounds_max_x' => 160,
|
||||||
|
'bounds_min_z' => -120,
|
||||||
|
'bounds_max_z' => 120,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'expansions' => 0,
|
'expansions' => 0,
|
||||||
],
|
],
|
||||||
|
|||||||
+246
-52
@@ -46,12 +46,17 @@ const sun = new THREE.DirectionalLight(0xfff2d9, 1.15);
|
|||||||
sun.position.set(80, 140, 60);
|
sun.position.set(80, 140, 60);
|
||||||
sun.castShadow = true;
|
sun.castShadow = true;
|
||||||
sun.shadow.mapSize.set(2048, 2048);
|
sun.shadow.mapSize.set(2048, 2048);
|
||||||
sun.shadow.camera.left = -200;
|
// Tight shadow window that follows the player (see updateDayNight): the
|
||||||
sun.shadow.camera.right = 200;
|
// city is far larger than the frustum now, and a follow box keeps the
|
||||||
sun.shadow.camera.top = 200;
|
// per-frame caster list small while doubling effective shadow resolution.
|
||||||
sun.shadow.camera.bottom = -200;
|
sun.shadow.camera.left = -120;
|
||||||
|
sun.shadow.camera.right = 120;
|
||||||
|
sun.shadow.camera.top = 120;
|
||||||
|
sun.shadow.camera.bottom = -120;
|
||||||
sun.shadow.camera.far = 500;
|
sun.shadow.camera.far = 500;
|
||||||
|
sun.shadow.bias = -0.0004;
|
||||||
scene.add(sun);
|
scene.add(sun);
|
||||||
|
scene.add(sun.target);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Texture helpers
|
// Texture helpers
|
||||||
@@ -129,8 +134,8 @@ function zebraTexture(barsAcrossU) {
|
|||||||
const zebraTexOverZRoad = zebraTexture(true);
|
const zebraTexOverZRoad = zebraTexture(true);
|
||||||
const zebraTexOverXRoad = zebraTexture(false);
|
const zebraTexOverXRoad = zebraTexture(false);
|
||||||
|
|
||||||
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
|
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4, size = 512) {
|
||||||
const w = 512, h = 512;
|
const w = size, h = size;
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas');
|
||||||
c.width = w; c.height = h;
|
c.width = w; c.height = h;
|
||||||
const ctx = c.getContext('2d');
|
const ctx = c.getContext('2d');
|
||||||
@@ -210,6 +215,8 @@ const lampHeads = [];
|
|||||||
let grid = null;
|
let grid = null;
|
||||||
let crosswalkCount = 0;
|
let crosswalkCount = 0;
|
||||||
let sidewalkSegmentCount = 0;
|
let sidewalkSegmentCount = 0;
|
||||||
|
// Per-frame cost split (update JS vs render submit) for perf monitoring.
|
||||||
|
const perfFrame = { update: 0, render: 0 };
|
||||||
let trafficSystem = null;
|
let trafficSystem = null;
|
||||||
let vehicleSystem = null;
|
let vehicleSystem = null;
|
||||||
let pedestrianSystem = null;
|
let pedestrianSystem = null;
|
||||||
@@ -319,7 +326,16 @@ function updateDayNight() {
|
|||||||
scene.fog.color.copy(sky);
|
scene.fog.color.copy(sky);
|
||||||
|
|
||||||
const ang = ((hour - 6) / 12) * Math.PI;
|
const ang = ((hour - 6) / 12) * Math.PI;
|
||||||
sun.position.set(Math.cos(ang) * 120, Math.max(8, Math.sin(ang) * 150), 60);
|
// Anchor sun + shadow frustum on the player so the shadow pass only
|
||||||
|
// sees casters around them, wherever they are in the expanded city.
|
||||||
|
const focus = player.pos;
|
||||||
|
sun.position.set(
|
||||||
|
focus.x + Math.cos(ang) * 120,
|
||||||
|
Math.max(8, Math.sin(ang) * 150),
|
||||||
|
focus.z + 60
|
||||||
|
);
|
||||||
|
sun.target.position.set(focus.x, 0, focus.z);
|
||||||
|
sun.target.updateMatrixWorld();
|
||||||
sun.intensity = (isDay ? elevation : 0) * 1.25 * weatherDim + lightningFlash * 2;
|
sun.intensity = (isDay ? elevation : 0) * 1.25 * weatherDim + lightningFlash * 2;
|
||||||
sun.color.setHSL(0.09, 0.55, isDay ? 0.55 + elevation * 0.4 : 0.5);
|
sun.color.setHSL(0.09, 0.55, isDay ? 0.55 + elevation * 0.4 : 0.5);
|
||||||
|
|
||||||
@@ -529,6 +545,76 @@ function stripSegments(lines, a, b, half) {
|
|||||||
return segs;
|
return segs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batches static same-material geometries into single merged meshes so a
|
||||||
|
* large city costs a handful of draw calls instead of thousands. Entries
|
||||||
|
* are baked into world space at flush(); only truly static, non-
|
||||||
|
* interactive meshes belong here (roadsides, poles, housings, roofs).
|
||||||
|
*
|
||||||
|
* @returns {{add: Function, flush: Function}}
|
||||||
|
*/
|
||||||
|
function makeStaticBatcher() {
|
||||||
|
/** @type {Map<THREE.Material, {entries: Array<{geometry: THREE.BufferGeometry, matrix: THREE.Matrix4}>, cast: boolean, receive: boolean}>} */
|
||||||
|
const buckets = new Map();
|
||||||
|
|
||||||
|
return {
|
||||||
|
add(geometry, material, matrix, cast = false, receive = false) {
|
||||||
|
let bucket = buckets.get(material);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { entries: [], cast: false, receive: false };
|
||||||
|
buckets.set(material, bucket);
|
||||||
|
}
|
||||||
|
bucket.entries.push({ geometry, matrix });
|
||||||
|
bucket.cast = bucket.cast || cast;
|
||||||
|
bucket.receive = bucket.receive || receive;
|
||||||
|
},
|
||||||
|
|
||||||
|
flush(parent) {
|
||||||
|
for (const [material, bucket] of buckets) {
|
||||||
|
if (!bucket.entries.length) continue;
|
||||||
|
|
||||||
|
const parts = bucket.entries.map(({ geometry, matrix }) => {
|
||||||
|
const g = geometry.index ? geometry.toNonIndexed() : geometry.clone();
|
||||||
|
if (matrix) g.applyMatrix4(matrix);
|
||||||
|
return g;
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (const g of parts) total += g.getAttribute('position').count;
|
||||||
|
|
||||||
|
const pos = new Float32Array(total * 3);
|
||||||
|
const nor = new Float32Array(total * 3);
|
||||||
|
const uv = new Float32Array(total * 2);
|
||||||
|
let o3 = 0;
|
||||||
|
let o2 = 0;
|
||||||
|
for (const g of parts) {
|
||||||
|
const count = g.getAttribute('position').count;
|
||||||
|
pos.set(g.getAttribute('position').array, o3);
|
||||||
|
const n = g.getAttribute('normal');
|
||||||
|
if (n) nor.set(n.array, o3);
|
||||||
|
const u = g.getAttribute('uv');
|
||||||
|
if (u) uv.set(u.array, o2);
|
||||||
|
o3 += count * 3;
|
||||||
|
o2 += count * 2;
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = new THREE.BufferGeometry();
|
||||||
|
merged.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||||
|
merged.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
|
||||||
|
merged.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||||
|
merged.computeBoundingSphere();
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(merged, material);
|
||||||
|
mesh.castShadow = bucket.cast;
|
||||||
|
mesh.receiveShadow = bucket.receive;
|
||||||
|
parent.add(mesh);
|
||||||
|
}
|
||||||
|
buckets.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Traffic light system
|
// Traffic light system
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -556,12 +642,18 @@ function pedStateForAxis(axis) {
|
|||||||
return vehicle === 'red' ? 'green' : 'red';
|
return vehicle === 'red' ? 'green' : 'red';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared housings: hundreds of signal bodies merge into one draw call
|
||||||
|
// while the lamp spheres stay individual (animated, distance-culled).
|
||||||
|
const pedHousingMat = new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 });
|
||||||
|
const signalHousingMat = new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 });
|
||||||
|
|
||||||
function makePedSignal() {
|
function makePedSignal() {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
const box = new THREE.Mesh(
|
const box = new THREE.Mesh(
|
||||||
new THREE.BoxGeometry(0.28, 0.7, 0.22),
|
new THREE.BoxGeometry(0.28, 0.7, 0.22),
|
||||||
new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 })
|
pedHousingMat
|
||||||
);
|
);
|
||||||
|
box.userData.batchStatic = true;
|
||||||
g.add(box);
|
g.add(box);
|
||||||
// Top = red "don't walk", bottom = green "walk".
|
// Top = red "don't walk", bottom = green "walk".
|
||||||
const red = new THREE.Mesh(
|
const red = new THREE.Mesh(
|
||||||
@@ -584,8 +676,9 @@ function makeSignalHead() {
|
|||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
const box = new THREE.Mesh(
|
const box = new THREE.Mesh(
|
||||||
new THREE.BoxGeometry(0.32, 1.0, 0.26),
|
new THREE.BoxGeometry(0.32, 1.0, 0.26),
|
||||||
new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 })
|
signalHousingMat
|
||||||
);
|
);
|
||||||
|
box.userData.batchStatic = true;
|
||||||
g.add(box);
|
g.add(box);
|
||||||
const colors = [0xff2222, 0xffcc00, 0x22cc44];
|
const colors = [0xff2222, 0xffcc00, 0x22cc44];
|
||||||
const lamps = [];
|
const lamps = [];
|
||||||
@@ -602,7 +695,23 @@ function makeSignalHead() {
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTrafficLights(g) {
|
/**
|
||||||
|
* Move a signal head's static housing into the batcher, leaving the
|
||||||
|
* animated lamp spheres in the group (they are distance-culled instead).
|
||||||
|
*
|
||||||
|
* @param {THREE.Group} head
|
||||||
|
* @param {ReturnType<typeof makeStaticBatcher>} batcher
|
||||||
|
*/
|
||||||
|
function batchHeadStatic(head, batcher) {
|
||||||
|
head.updateMatrixWorld(true);
|
||||||
|
for (const child of [...head.children]) {
|
||||||
|
if (!child.userData.batchStatic) continue;
|
||||||
|
batcher.add(child.geometry, child.material, child.matrixWorld);
|
||||||
|
head.remove(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTrafficLights(g, batcher) {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
const heads = [];
|
const heads = [];
|
||||||
const pedHeads = [];
|
const pedHeads = [];
|
||||||
@@ -615,33 +724,41 @@ function initTrafficLights(g) {
|
|||||||
|
|
||||||
for (const x of g.xs) {
|
for (const x of g.xs) {
|
||||||
for (const z of g.zs) {
|
for (const z of g.zs) {
|
||||||
// SE-corner pole, planted on the sidewalk.
|
// SE-corner pole, planted on the sidewalk (group/world are at
|
||||||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6), poleMat);
|
// identity, so local matrices are world matrices here).
|
||||||
|
const poleGeo = new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6);
|
||||||
|
const pole = new THREE.Mesh(poleGeo, poleMat);
|
||||||
pole.position.set(x + sw, 2.1, z + sw);
|
pole.position.set(x + sw, 2.1, z + sw);
|
||||||
pole.castShadow = true;
|
pole.updateMatrix();
|
||||||
group.add(pole);
|
batcher.add(poleGeo, poleMat, pole.matrix, true);
|
||||||
|
|
||||||
// Mast arm over the vertical road (northbound lane), facing
|
// Mast arm over the vertical road (northbound lane), facing
|
||||||
// south so approaching traffic sees the signal. Northbound
|
// south so approaching traffic sees the signal. Northbound
|
||||||
// drives on the east half (right-hand traffic); the SE-corner
|
// drives on the east half (right-hand traffic); the SE-corner
|
||||||
// pole sits on that same (right) side.
|
// pole sits on that same (right) side.
|
||||||
const armNS = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.1, 2.2), poleMat);
|
const armNSGeo = new THREE.BoxGeometry(0.12, 0.1, 2.2);
|
||||||
|
const armNS = new THREE.Mesh(armNSGeo, poleMat);
|
||||||
armNS.position.set(x + sw, 4.0, z + sw - 1.1);
|
armNS.position.set(x + sw, 4.0, z + sw - 1.1);
|
||||||
group.add(armNS);
|
armNS.updateMatrix();
|
||||||
|
batcher.add(armNSGeo, poleMat, armNS.matrix);
|
||||||
const headNS = makeSignalHead();
|
const headNS = makeSignalHead();
|
||||||
headNS.position.set(x + sw, 3.7, z + sw - 2.2);
|
headNS.position.set(x + sw, 3.7, z + sw - 2.2);
|
||||||
headNS.rotation.y = 0; // face +Z (south — toward northbound traffic)
|
headNS.rotation.y = 0; // face +Z (south — toward northbound traffic)
|
||||||
group.add(headNS);
|
group.add(headNS);
|
||||||
|
batchHeadStatic(headNS, batcher);
|
||||||
heads.push({ head: headNS, axis: 'z' });
|
heads.push({ head: headNS, axis: 'z' });
|
||||||
|
|
||||||
// Mast arm over the horizontal road (eastbound lane), facing west.
|
// Mast arm over the horizontal (eastbound lane), facing west.
|
||||||
const armEW = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.1, 0.12), poleMat);
|
const armEWGeo = new THREE.BoxGeometry(2.2, 0.1, 0.12);
|
||||||
|
const armEW = new THREE.Mesh(armEWGeo, poleMat);
|
||||||
armEW.position.set(x + sw - 1.1, 4.0, z + sw);
|
armEW.position.set(x + sw - 1.1, 4.0, z + sw);
|
||||||
group.add(armEW);
|
armEW.updateMatrix();
|
||||||
|
batcher.add(armEWGeo, poleMat, armEW.matrix);
|
||||||
const headEW = makeSignalHead();
|
const headEW = makeSignalHead();
|
||||||
headEW.position.set(x + sw - 2.2, 3.7, z + sw);
|
headEW.position.set(x + sw - 2.2, 3.7, z + sw);
|
||||||
headEW.rotation.y = -Math.PI / 2; // face -X (west)
|
headEW.rotation.y = -Math.PI / 2; // face -X (west)
|
||||||
group.add(headEW);
|
group.add(headEW);
|
||||||
|
batchHeadStatic(headEW, batcher);
|
||||||
heads.push({ head: headEW, axis: 'x' });
|
heads.push({ head: headEW, axis: 'x' });
|
||||||
|
|
||||||
// Pedestrian signals: short posts on the same corner, well
|
// Pedestrian signals: short posts on the same corner, well
|
||||||
@@ -651,24 +768,30 @@ function initTrafficLights(g) {
|
|||||||
// signal follows the x-axis vehicle state. It sits on the east
|
// signal follows the x-axis vehicle state. It sits on the east
|
||||||
// sidewalk facing north, for walkers arriving from the north.
|
// sidewalk facing north, for walkers arriving from the north.
|
||||||
const pedEast = makePedSignal();
|
const pedEast = makePedSignal();
|
||||||
const pedEastPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
|
const pedEastPoleGeo = new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6);
|
||||||
|
const pedEastPole = new THREE.Mesh(pedEastPoleGeo, pedPoleMat);
|
||||||
pedEastPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
|
pedEastPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
|
||||||
group.add(pedEastPole);
|
pedEastPole.updateMatrix();
|
||||||
|
batcher.add(pedEastPoleGeo, pedPoleMat, pedEastPole.matrix);
|
||||||
pedEast.position.set(x + sw + 0.6, 2.5, z + sw - 0.6);
|
pedEast.position.set(x + sw + 0.6, 2.5, z + sw - 0.6);
|
||||||
pedEast.rotation.y = Math.PI; // faces north (approach from the north)
|
pedEast.rotation.y = Math.PI; // faces north (approach from the north)
|
||||||
group.add(pedEast);
|
group.add(pedEast);
|
||||||
|
batchHeadStatic(pedEast, batcher);
|
||||||
pedHeads.push({ head: pedEast, axis: 'x' });
|
pedHeads.push({ head: pedEast, axis: 'x' });
|
||||||
|
|
||||||
// The south crosswalk crosses the vertical (z) road, so its
|
// The south crosswalk crosses the vertical (z) road, so its
|
||||||
// signal follows the z-axis vehicle state. It sits on the south
|
// signal follows the z-axis vehicle state. It sits on the south
|
||||||
// sidewalk facing west, for walkers arriving from the east.
|
// sidewalk facing west, for walkers arriving from the east.
|
||||||
const pedSouth = makePedSignal();
|
const pedSouth = makePedSignal();
|
||||||
const pedSouthPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
|
const pedSouthPoleGeo = new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6);
|
||||||
|
const pedSouthPole = new THREE.Mesh(pedSouthPoleGeo, pedPoleMat);
|
||||||
pedSouthPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
|
pedSouthPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
|
||||||
group.add(pedSouthPole);
|
pedSouthPole.updateMatrix();
|
||||||
|
batcher.add(pedSouthPoleGeo, pedPoleMat, pedSouthPole.matrix);
|
||||||
pedSouth.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
|
pedSouth.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
|
||||||
pedSouth.rotation.y = -Math.PI / 2; // faces west (approach from the east)
|
pedSouth.rotation.y = -Math.PI / 2; // faces west (approach from the east)
|
||||||
group.add(pedSouth);
|
group.add(pedSouth);
|
||||||
|
batchHeadStatic(pedSouth, batcher);
|
||||||
pedHeads.push({ head: pedSouth, axis: 'z' });
|
pedHeads.push({ head: pedSouth, axis: 'z' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -714,6 +837,21 @@ function initTrafficLights(g) {
|
|||||||
lamps.red.material.emissiveIntensity = ped === 'red' ? 2.4 : 0.06;
|
lamps.red.material.emissiveIntensity = ped === 'red' ? 2.4 : 0.06;
|
||||||
lamps.green.material.emissiveIntensity = ped === 'green' ? 2.4 : 0.06;
|
lamps.green.material.emissiveIntensity = ped === 'green' ? 2.4 : 0.06;
|
||||||
}
|
}
|
||||||
|
// Lamp dots are sub-pixel at distance — hide them far away.
|
||||||
|
// Housings are batched separately and always stay visible.
|
||||||
|
const camX = camera.position.x;
|
||||||
|
const camZ = camera.position.z;
|
||||||
|
const hideD2 = 60 * 60;
|
||||||
|
for (const { head } of this.heads) {
|
||||||
|
const dx = head.position.x - camX;
|
||||||
|
const dz = head.position.z - camZ;
|
||||||
|
head.visible = dx * dx + dz * dz < hideD2;
|
||||||
|
}
|
||||||
|
for (const { head } of this.pedHeads) {
|
||||||
|
const dx = head.position.x - camX;
|
||||||
|
const dz = head.position.z - camZ;
|
||||||
|
head.visible = dx * dx + dz * dz < hideD2;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -857,7 +995,7 @@ function buildCarMesh(colorHex, isBus = false) {
|
|||||||
[[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => {
|
[[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => {
|
||||||
const w = new THREE.Mesh(wheelGeo, rubber);
|
const w = new THREE.Mesh(wheelGeo, rubber);
|
||||||
w.position.set(px, 0.34, pz);
|
w.position.set(px, 0.34, pz);
|
||||||
w.castShadow = true;
|
w.castShadow = false; w.userData.lodDetail = true;
|
||||||
g.add(w);
|
g.add(w);
|
||||||
const rim = new THREE.Mesh(rimGeo, chrome);
|
const rim = new THREE.Mesh(rimGeo, chrome);
|
||||||
rim.position.set(px, 0.34, pz);
|
rim.position.set(px, 0.34, pz);
|
||||||
@@ -918,7 +1056,7 @@ function buildMotoMesh(colorHex) {
|
|||||||
[[0.74, 0], [-0.56, 0]].forEach(([px]) => {
|
[[0.74, 0], [-0.56, 0]].forEach(([px]) => {
|
||||||
const w = new THREE.Mesh(wheelGeo, rubber);
|
const w = new THREE.Mesh(wheelGeo, rubber);
|
||||||
w.position.set(px, 0.34, 0);
|
w.position.set(px, 0.34, 0);
|
||||||
w.castShadow = true;
|
w.castShadow = false; w.userData.lodDetail = true;
|
||||||
g.add(w);
|
g.add(w);
|
||||||
const rim = new THREE.Mesh(rimGeo, chrome);
|
const rim = new THREE.Mesh(rimGeo, chrome);
|
||||||
rim.position.set(px, 0.34, 0);
|
rim.position.set(px, 0.34, 0);
|
||||||
@@ -1017,7 +1155,7 @@ function buildBikeMesh(colorHex) {
|
|||||||
[[0.58], [-0.58]].forEach(([px]) => {
|
[[0.58], [-0.58]].forEach(([px]) => {
|
||||||
const w = new THREE.Mesh(wheelGeo, rubber);
|
const w = new THREE.Mesh(wheelGeo, rubber);
|
||||||
w.position.set(px, 0.34, 0);
|
w.position.set(px, 0.34, 0);
|
||||||
w.castShadow = true;
|
w.castShadow = false; w.userData.lodDetail = true;
|
||||||
g.add(w);
|
g.add(w);
|
||||||
// Spokes.
|
// Spokes.
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
@@ -1169,6 +1307,8 @@ function initVehicles(g) {
|
|||||||
else mesh = buildCarMesh(color, kind === 'bus');
|
else mesh = buildCarMesh(color, kind === 'bus');
|
||||||
|
|
||||||
worldGroup.add(mesh);
|
worldGroup.add(mesh);
|
||||||
|
const lodDetail = [];
|
||||||
|
mesh.traverse(o => { if (o.userData.lodDetail) lodDetail.push(o); });
|
||||||
const v = {
|
const v = {
|
||||||
mesh, pts: loop.pts, L: loop.L,
|
mesh, pts: loop.pts, L: loop.L,
|
||||||
s: offsetS, speed: 0, kind,
|
s: offsetS, speed: 0, kind,
|
||||||
@@ -1176,6 +1316,7 @@ function initVehicles(g) {
|
|||||||
accel: kind === 'moto' ? 7 : kind === 'bike' ? 3.5 : 5,
|
accel: kind === 'moto' ? 7 : kind === 'bike' ? 3.5 : 5,
|
||||||
decel: 10,
|
decel: 10,
|
||||||
loopKey: loop,
|
loopKey: loop,
|
||||||
|
lodDetail, lodFar: false,
|
||||||
};
|
};
|
||||||
vehicles.push(v);
|
vehicles.push(v);
|
||||||
return v;
|
return v;
|
||||||
@@ -1207,6 +1348,9 @@ function initVehicles(g) {
|
|||||||
}
|
}
|
||||||
for (const sh of shuttles) {
|
for (const sh of shuttles) {
|
||||||
worldGroup.add(sh.mesh);
|
worldGroup.add(sh.mesh);
|
||||||
|
sh.lodDetail = [];
|
||||||
|
sh.mesh.traverse(o => { if (o.userData.lodDetail) sh.lodDetail.push(o); });
|
||||||
|
sh.lodFar = false;
|
||||||
vehicles.push(sh);
|
vehicles.push(sh);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,12 +1377,26 @@ function initVehicles(g) {
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
update(dt) {
|
update(dt) {
|
||||||
|
const camX = camera.position.x;
|
||||||
|
const camZ = camera.position.z;
|
||||||
|
const lodD2 = 45 * 45;
|
||||||
for (const v of vehicles) {
|
for (const v of vehicles) {
|
||||||
if (v.pts) {
|
if (v.pts) {
|
||||||
updateLoopVehicle(v, dt);
|
updateLoopVehicle(v, dt);
|
||||||
} else {
|
} else {
|
||||||
updateShuttleVehicle(v, dt);
|
updateShuttleVehicle(v, dt);
|
||||||
}
|
}
|
||||||
|
// Wheels past the near band are a handful of pixels — skip
|
||||||
|
// their draw calls (body/cabin stay; state flips only on change).
|
||||||
|
if (v.lodDetail && v.lodDetail.length) {
|
||||||
|
const dx = v.mesh.position.x - camX;
|
||||||
|
const dz = v.mesh.position.z - camZ;
|
||||||
|
const far = dx * dx + dz * dz > lodD2;
|
||||||
|
if (far !== v.lodFar) {
|
||||||
|
v.lodFar = far;
|
||||||
|
for (const part of v.lodDetail) part.visible = !far;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2845,6 +3003,8 @@ function buildCity(data) {
|
|||||||
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
|
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
|
||||||
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
|
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
|
||||||
walkMat.map.repeat.set(4, 1);
|
walkMat.map.repeat.set(4, 1);
|
||||||
|
// Static same-material geometry is merged into single draw calls.
|
||||||
|
const staticBatch = makeStaticBatcher();
|
||||||
|
|
||||||
layout.roads.forEach(r => {
|
layout.roads.forEach(r => {
|
||||||
const [x1,, z1] = r.from;
|
const [x1,, z1] = r.from;
|
||||||
@@ -2861,7 +3021,8 @@ function buildCity(data) {
|
|||||||
mesh.position.set(mx, 0.01, mz);
|
mesh.position.set(mx, 0.01, mz);
|
||||||
if (r === layoutExtras.bridgeRoad) mesh.position.y = 0.07;
|
if (r === layoutExtras.bridgeRoad) mesh.position.y = 0.07;
|
||||||
mesh.receiveShadow = true;
|
mesh.receiveShadow = true;
|
||||||
worldGroup.add(mesh);
|
mesh.updateMatrix();
|
||||||
|
staticBatch.add(geo, roadMat, mesh.matrix, false, true);
|
||||||
|
|
||||||
if (r === layoutExtras.bridgeRoad) return; // bridge has its own deck
|
if (r === layoutExtras.bridgeRoad) return; // bridge has its own deck
|
||||||
|
|
||||||
@@ -2888,13 +3049,15 @@ function buildCity(data) {
|
|||||||
sm.position.set(cmid, 0.12, mz + side * offset);
|
sm.position.set(cmid, 0.12, mz + side * offset);
|
||||||
}
|
}
|
||||||
sm.receiveShadow = true;
|
sm.receiveShadow = true;
|
||||||
worldGroup.add(sm);
|
sm.updateMatrix();
|
||||||
|
staticBatch.add(sg, walkMat, sm.matrix, false, true);
|
||||||
sidewalkSegmentCount++;
|
sidewalkSegmentCount++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Curbs (interrupted at crosswalks — dropped-curb look) ----
|
// ---- Curbs (interrupted at crosswalks — dropped-curb look) ----
|
||||||
|
const curbMat = new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 });
|
||||||
layout.roads.forEach(r => {
|
layout.roads.forEach(r => {
|
||||||
if (r === layoutExtras.bridgeRoad) return;
|
if (r === layoutExtras.bridgeRoad) return;
|
||||||
const [x1,, z1] = r.from;
|
const [x1,, z1] = r.from;
|
||||||
@@ -2913,13 +3076,14 @@ function buildCity(data) {
|
|||||||
const curbGeo = r.axis === 'z'
|
const curbGeo = r.axis === 'z'
|
||||||
? new THREE.BoxGeometry(0.2, 0.14, segLen)
|
? new THREE.BoxGeometry(0.2, 0.14, segLen)
|
||||||
: new THREE.BoxGeometry(segLen, 0.14, 0.2);
|
: new THREE.BoxGeometry(segLen, 0.14, 0.2);
|
||||||
const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }));
|
const curb = new THREE.Mesh(curbGeo, curbMat);
|
||||||
if (r.axis === 'z') {
|
if (r.axis === 'z') {
|
||||||
curb.position.set(mx + side * offset, 0.07, cmid);
|
curb.position.set(mx + side * offset, 0.07, cmid);
|
||||||
} else {
|
} else {
|
||||||
curb.position.set(cmid, 0.07, mz + side * offset);
|
curb.position.set(cmid, 0.07, mz + side * offset);
|
||||||
}
|
}
|
||||||
worldGroup.add(curb);
|
curb.updateMatrix();
|
||||||
|
staticBatch.add(curbGeo, curbMat, curb.matrix);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2956,7 +3120,8 @@ function buildCity(data) {
|
|||||||
zebra.rotation.x = -Math.PI / 2;
|
zebra.rotation.x = -Math.PI / 2;
|
||||||
zebra.position.set(vx, 0.02, vz + side * (crossHalf + crossBand / 2));
|
zebra.position.set(vx, 0.02, vz + side * (crossHalf + crossBand / 2));
|
||||||
zebra.receiveShadow = true;
|
zebra.receiveShadow = true;
|
||||||
worldGroup.add(zebra);
|
zebra.updateMatrix();
|
||||||
|
staticBatch.add(zebra.geometry, zebraMatOverZ, zebra.matrix, false, true);
|
||||||
crosswalkCount++;
|
crosswalkCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2974,7 +3139,8 @@ function buildCity(data) {
|
|||||||
zebra.rotation.x = -Math.PI / 2;
|
zebra.rotation.x = -Math.PI / 2;
|
||||||
zebra.position.set(vx + side * (crossHalf + crossBand / 2), 0.02, vz);
|
zebra.position.set(vx + side * (crossHalf + crossBand / 2), 0.02, vz);
|
||||||
zebra.receiveShadow = true;
|
zebra.receiveShadow = true;
|
||||||
worldGroup.add(zebra);
|
zebra.updateMatrix();
|
||||||
|
staticBatch.add(zebra.geometry, zebraMatOverX, zebra.matrix, false, true);
|
||||||
crosswalkCount++;
|
crosswalkCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2982,6 +3148,9 @@ function buildCity(data) {
|
|||||||
|
|
||||||
// ---- Street lamps (on the sidewalk, never in the roadway) ----
|
// ---- Street lamps (on the sidewalk, never in the roadway) ----
|
||||||
const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 });
|
const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 });
|
||||||
|
// One shared material for every cobra head: all fade together at night.
|
||||||
|
const streetHeadMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 });
|
||||||
|
lampHeads.push(streetHeadMat);
|
||||||
const lampSwOffset = grid.roadW / 2 + 1; // sidewalk centre = 5 from road centre
|
const lampSwOffset = grid.roadW / 2 + 1; // sidewalk centre = 5 from road centre
|
||||||
const nearIntersection = (lx, lz) => {
|
const nearIntersection = (lx, lz) => {
|
||||||
// Skip spots within 7 units of any crossing so lamps never sit
|
// Skip spots within 7 units of any crossing so lamps never sit
|
||||||
@@ -3008,30 +3177,32 @@ function buildCity(data) {
|
|||||||
|
|
||||||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat);
|
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat);
|
||||||
pole.position.set(px, 2.25, pz);
|
pole.position.set(px, 2.25, pz);
|
||||||
pole.castShadow = true;
|
pole.updateMatrix();
|
||||||
worldGroup.add(pole);
|
staticBatch.add(pole.geometry, lampPoleMat, pole.matrix, true);
|
||||||
|
|
||||||
// Curved arm reaching over the curb toward the road.
|
// Curved arm reaching over the curb toward the road.
|
||||||
const dirX = r.axis === 'z' ? -side : 0;
|
const dirX = r.axis === 'z' ? -side : 0;
|
||||||
const dirZ = r.axis === 'x' ? -side : 0;
|
const dirZ = r.axis === 'x' ? -side : 0;
|
||||||
const arm = new THREE.Mesh(
|
const armGeo = new THREE.BoxGeometry(r.axis === 'z' ? 1.3 : 0.1, 0.08, r.axis === 'x' ? 1.3 : 0.1);
|
||||||
new THREE.BoxGeometry(r.axis === 'z' ? 1.3 : 0.1, 0.08, r.axis === 'x' ? 1.3 : 0.1),
|
const arm = new THREE.Mesh(armGeo, lampPoleMat);
|
||||||
lampPoleMat
|
|
||||||
);
|
|
||||||
arm.position.set(px + dirX * 0.6, 4.55, pz + dirZ * 0.6);
|
arm.position.set(px + dirX * 0.6, 4.55, pz + dirZ * 0.6);
|
||||||
worldGroup.add(arm);
|
arm.updateMatrix();
|
||||||
|
staticBatch.add(armGeo, lampPoleMat, arm.matrix);
|
||||||
|
|
||||||
const headMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 });
|
const headGeo = new THREE.BoxGeometry(0.55, 0.18, 0.28);
|
||||||
// Compact cobra-style head sitting at the arm tip, hanging slightly down.
|
// Compact cobra-style head sitting at the arm tip, hanging slightly down.
|
||||||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.18, 0.28), headMat);
|
const head = new THREE.Mesh(headGeo, streetHeadMat);
|
||||||
head.position.set(px + dirX * 1.2, 4.45, pz + dirZ * 1.2);
|
head.position.set(px + dirX * 1.2, 4.45, pz + dirZ * 1.2);
|
||||||
worldGroup.add(head);
|
head.updateMatrix();
|
||||||
lampHeads.push(headMat);
|
staticBatch.add(headGeo, streetHeadMat, head.matrix);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Traffic lights ----
|
// ---- Traffic lights ----
|
||||||
initTrafficLights(grid);
|
initTrafficLights(grid, staticBatch);
|
||||||
|
// Merge every static bucket collected so far (roadsides, poles,
|
||||||
|
// housings, lamp heads) into one mesh per material.
|
||||||
|
staticBatch.flush(worldGroup);
|
||||||
|
|
||||||
// ---- Stores ----
|
// ---- Stores ----
|
||||||
const palette = ['#c9a84c', '#b8763a', '#8a7a5a', '#9a9a8a', '#a08060', '#7a8a6a'];
|
const palette = ['#c9a84c', '#b8763a', '#8a7a5a', '#9a9a8a', '#a08060', '#7a8a6a'];
|
||||||
@@ -3089,7 +3260,15 @@ function buildCity(data) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- Filler buildings ----
|
// ---- Filler buildings ----
|
||||||
const occupied = stores.map(s => ({ x: s.position[0], z: s.position[2] }));
|
// Fillers only exist on empty ground: skip cells near stores AND near
|
||||||
|
// any placed building (hospital, school, city hall, player houses) so
|
||||||
|
// nothing ever spawns on top of a real structure.
|
||||||
|
const occupied = [
|
||||||
|
...stores.map(s => ({ x: s.position[0], z: s.position[2] })),
|
||||||
|
...(data.buildings || []).map(b => ({ x: b.position[0], z: b.position[2] })),
|
||||||
|
];
|
||||||
|
const fillerRoofMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
|
||||||
|
const roofBatch = makeStaticBatcher();
|
||||||
(layout.blocks || []).forEach((b, bi) => {
|
(layout.blocks || []).forEach((b, bi) => {
|
||||||
const cx = (b.min[0] + b.max[0]) / 2;
|
const cx = (b.min[0] + b.max[0]) / 2;
|
||||||
const cz = (b.min[2] + b.max[2]) / 2;
|
const cz = (b.min[2] + b.max[2]) / 2;
|
||||||
@@ -3101,7 +3280,9 @@ function buildCity(data) {
|
|||||||
const bh = 6 + (bi * 13) % 20;
|
const bh = 6 + (bi * 13) % 20;
|
||||||
const color = palette[(bi + 2) % palette.length];
|
const color = palette[(bi + 2) % palette.length];
|
||||||
const floors = Math.max(2, Math.round(bh / 3));
|
const floors = Math.max(2, Math.round(bh / 3));
|
||||||
const pair = facadePair(color, floors, Math.max(2, Math.round(bw / 3)));
|
// Filler blocks repeat across the whole city — half-resolution
|
||||||
|
// facades keep VRAM flat while the city grows (stores keep 512).
|
||||||
|
const pair = facadePair(color, floors, Math.max(2, Math.round(bw / 3)), 256);
|
||||||
|
|
||||||
const geo = new THREE.BoxGeometry(bw, bh, bd);
|
const geo = new THREE.BoxGeometry(bw, bh, bd);
|
||||||
const sideMat = new THREE.MeshStandardMaterial({
|
const sideMat = new THREE.MeshStandardMaterial({
|
||||||
@@ -3109,11 +3290,10 @@ function buildCity(data) {
|
|||||||
emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85,
|
emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85,
|
||||||
});
|
});
|
||||||
facadeMaterials.push(sideMat);
|
facadeMaterials.push(sideMat);
|
||||||
const mats = Array.from({ length: 6 }, () => sideMat);
|
// One material → one draw call per building (the roof slab covers
|
||||||
mats[2] = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 });
|
// the top face; multi-material boxes cost up to six calls each).
|
||||||
mats[3] = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9 });
|
|
||||||
|
|
||||||
const m = new THREE.Mesh(geo, mats);
|
const m = new THREE.Mesh(geo, sideMat);
|
||||||
m.position.set(cx, bh / 2, cz);
|
m.position.set(cx, bh / 2, cz);
|
||||||
m.castShadow = true;
|
m.castShadow = true;
|
||||||
m.receiveShadow = true;
|
m.receiveShadow = true;
|
||||||
@@ -3122,11 +3302,13 @@ function buildCity(data) {
|
|||||||
|
|
||||||
const roof = new THREE.Mesh(
|
const roof = new THREE.Mesh(
|
||||||
new THREE.BoxGeometry(bw + 0.3, 0.3, bd + 0.3),
|
new THREE.BoxGeometry(bw + 0.3, 0.3, bd + 0.3),
|
||||||
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 })
|
fillerRoofMat
|
||||||
);
|
);
|
||||||
roof.position.set(cx, bh + 0.15, cz);
|
roof.position.set(cx, bh + 0.15, cz);
|
||||||
worldGroup.add(roof);
|
roof.updateMatrix();
|
||||||
|
roofBatch.add(roof.geometry, fillerRoofMat, roof.matrix);
|
||||||
});
|
});
|
||||||
|
roofBatch.flush(worldGroup);
|
||||||
|
|
||||||
// ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ----
|
// ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ----
|
||||||
const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper']);
|
const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper']);
|
||||||
@@ -4245,6 +4427,7 @@ function animate() {
|
|||||||
const dt = Math.min(0.05, (now - lastT) / 1000);
|
const dt = Math.min(0.05, (now - lastT) / 1000);
|
||||||
lastT = now;
|
lastT = now;
|
||||||
simTimeAcc += dt;
|
simTimeAcc += dt;
|
||||||
|
const tUpdate = performance.now();
|
||||||
|
|
||||||
updateClock(dt);
|
updateClock(dt);
|
||||||
updateWeather(dt);
|
updateWeather(dt);
|
||||||
@@ -4281,7 +4464,10 @@ function animate() {
|
|||||||
statsEl.textContent = buildStatsText();
|
statsEl.textContent = buildStatsText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
perfFrame.update = performance.now() - tUpdate;
|
||||||
|
const tRender = performance.now();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
|
perfFrame.render = performance.now() - tRender;
|
||||||
}
|
}
|
||||||
animate();
|
animate();
|
||||||
|
|
||||||
@@ -4810,6 +4996,14 @@ window.__city10 = {
|
|||||||
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
|
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
|
||||||
crosswalks: crosswalkCount,
|
crosswalks: crosswalkCount,
|
||||||
sidewalkSegments: sidewalkSegmentCount,
|
sidewalkSegments: sidewalkSegmentCount,
|
||||||
|
render: {
|
||||||
|
calls: renderer.info.render.calls,
|
||||||
|
triangles: renderer.info.render.triangles,
|
||||||
|
geometries: renderer.info.memory.geometries,
|
||||||
|
textures: renderer.info.memory.textures,
|
||||||
|
updateMs: +perfFrame.update.toFixed(1),
|
||||||
|
submitMs: +perfFrame.render.toFixed(1),
|
||||||
|
},
|
||||||
airplanes: airplaneSystem ? airplaneSystem.count() : 0,
|
airplanes: airplaneSystem ? airplaneSystem.count() : 0,
|
||||||
trains: trainSystem ? 1 : 0,
|
trains: trainSystem ? 1 : 0,
|
||||||
river: !!riverSystem,
|
river: !!riverSystem,
|
||||||
|
|||||||
@@ -44,4 +44,36 @@ class CityWorldSeedingTest extends TestCase
|
|||||||
$active = User::where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)->count();
|
$active = User::where('is_active', true)->where('status', '!=', User::STATUS_ACTIVE)->count();
|
||||||
$this->assertSame(0, $active);
|
$this->assertSame(0, $active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The performance-tuned city delivers exactly 72 blocks (9 × 8) with
|
||||||
|
* the matching road grid and bounds in the world payload.
|
||||||
|
*/
|
||||||
|
public function test_layout_has_72_blocks_after_performance_resize(): void
|
||||||
|
{
|
||||||
|
$this->seed(CityWorldSeeder::class);
|
||||||
|
|
||||||
|
$user = User::factory()->create(['status' => User::STATUS_ACTIVE, 'is_active' => true]);
|
||||||
|
Character::ensureForUser($user);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$payload = $this->getJson('/city/data')->assertOk()->json();
|
||||||
|
|
||||||
|
$this->assertCount(72, $payload['layout']['blocks']);
|
||||||
|
|
||||||
|
$zRoads = count(array_filter($payload['layout']['roads'], fn (array $r): bool => $r['axis'] === 'z'));
|
||||||
|
$xRoads = count(array_filter($payload['layout']['roads'], fn (array $r): bool => $r['axis'] === 'x'));
|
||||||
|
// Server payload: 10 vertical + 9 horizontal grid roads (the bridge
|
||||||
|
// and north extension are appended client-side at build time).
|
||||||
|
$this->assertSame(10, $zRoads);
|
||||||
|
$this->assertSame(9, $xRoads);
|
||||||
|
|
||||||
|
$this->assertEquals(-120, $payload['city']['bounds']['min_x']);
|
||||||
|
$this->assertEquals(160, $payload['city']['bounds']['max_x']);
|
||||||
|
$this->assertEquals(-120, $payload['city']['bounds']['min_z']);
|
||||||
|
$this->assertEquals(120, $payload['city']['bounds']['max_z']);
|
||||||
|
|
||||||
|
// The sparse plaza pattern (~1 in 4 cells) scales with the city.
|
||||||
|
$this->assertGreaterThanOrEqual(10, count($payload['plazas']));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user