feat: add factory sample machines and robot pricing conversations

This commit is contained in:
2026-10-28 12:53:07 +03:30
parent d0da416923
commit 55d4123fe5
71 changed files with 8179 additions and 3978 deletions
+119 -95
View File
@@ -14,37 +14,41 @@
// vertex count u8, height in 0.5 m u8, first vertex (x u16, z u16) in `step` metres from that origin,
// then each further vertex as (dx i16, dz i16) from the one before. Buildings are sorted west to
// east in 100 m strips, so neighbours sit next to each other and the small deltas gzip well.
import {createReadStream, readFileSync, writeFileSync, mkdirSync} from 'node:fs';
import {createGunzip} from 'node:zlib';
import {createInterface} from 'node:readline';
import {toLocal} from '../src/utils/geo.js';
import {FACTORY} from '../src/constants/locations.js';
import {createCollisionWorld, blocked} from '../src/game/world/collision.js';
import { createReadStream, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { createGunzip } from 'node:zlib';
import { createInterface } from 'node:readline';
import { toLocal } from '../src/utils/geo.js';
import { FACTORY } from '../src/constants/locations.js';
import { createCollisionWorld, blocked } from '../src/game/world/collision.js';
const [source] = process.argv.slice(2);
if (!source) {
console.error('usage: node scripts/build-buildings.mjs <global-buildings .csv.gz>');
process.exit(1);
console.error('usage: node scripts/build-buildings.mjs <global-buildings .csv.gz>');
process.exit(1);
}
const STEP = .5;
const STEP = 0.5;
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
const {minX, minZ, maxX, maxZ} = data.bounds;
const osm = createCollisionWorld(data.buildings.map(b => b.slice(1)));
const areasOf = kind => data.areas.filter(a => data.areaKinds[a[0]] === kind).map(a => a.slice(1));
const industrial = createCollisionWorld(areasOf('industrial')), parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]);
const { minX, minZ, maxX, maxZ } = data.bounds;
const osm = createCollisionWorld(data.buildings.map((b) => b.slice(1)));
const areasOf = (kind) =>
data.areas.filter((a) => data.areaKinds[a[0]] === kind).map((a) => a.slice(1));
const industrial = createCollisionWorld(areasOf('industrial')),
parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]);
// The factory compound in its own frame (u along the driveway, v across), with a margin.
const normal = {x: -FACTORY.axis.z, z: FACTORY.axis.x};
const normal = { x: -FACTORY.axis.z, z: FACTORY.axis.x };
const inFactory = (x, z) => {
const dx = x - FACTORY.center.x, dz = z - FACTORY.center.z;
const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, v = dx * normal.x + dz * normal.z;
return u > -115 && u < 85 && v > -55 && v < 55;
const dx = x - FACTORY.center.x,
dz = z - FACTORY.center.z;
const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z,
v = dx * normal.x + dz * normal.z;
return u > -115 && u < 85 && v > -55 && v < 55;
};
const hash = n => {
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
const hash = (n) => {
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
};
// Outside the Payambar-e Azam ring (west of ~59.47°E or north of ~36.40°N) the region is industrial
@@ -52,99 +56,119 @@ const hash = n => {
const outskirts = (x, z) => x < -6000 || z < -9400;
// Storeys of 3.2 m, picked from the footprint area; warehouses are one tall storey.
export function estimateHeight(area, {inIndustrial, inPark, inOutskirts}, seed) {
const r = hash(seed), pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1));
if (inPark) return 3.2;
if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3;
if (inOutskirts) return pick(1, 2) * 3.2;
if (inIndustrial && area >= 150) return 7 + r * 3;
if (area < 40) return 3.2;
if (area < 120) return pick(2, 3) * 3.2;
if (area < 300) return pick(3, 4) * 3.2;
if (area < 800) return pick(4, 6) * 3.2;
return pick(5, 8) * 3.2;
export function estimateHeight(area, { inIndustrial, inPark, inOutskirts }, seed) {
const r = hash(seed),
pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1));
if (inPark) return 3.2;
if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3;
if (inOutskirts) return pick(1, 2) * 3.2;
if (inIndustrial && area >= 150) return 7 + r * 3;
if (area < 40) return 3.2;
if (area < 120) return pick(2, 3) * 3.2;
if (area < 300) return pick(3, 4) * 3.2;
if (area < 800) return pick(4, 6) * 3.2;
return pick(5, 8) * 3.2;
}
const polygonArea = pts => {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return Math.abs(a / 2);
const polygonArea = (pts) => {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return Math.abs(a / 2);
};
const out = [];
let read = 0, outside = 0, duplicate = 0, factory = 0, tiny = 0;
const lines = createInterface({input: createReadStream(source).pipe(createGunzip())});
let read = 0,
outside = 0,
duplicate = 0,
factory = 0,
tiny = 0;
const lines = createInterface({ input: createReadStream(source).pipe(createGunzip()) });
for await (const line of lines) {
if (!line.trim()) continue;
read++;
const ring = JSON.parse(line).geometry.coordinates[0];
const pts = [];
for (const [lon, lat] of ring.slice(0, -1)) {
const p = toLocal(lat, lon);
pts.push(p.x, p.z);
}
let cx = 0, cz = 0;
for (let i = 0; i < pts.length; i += 2) {
cx += pts[i];
cz += pts[i + 1];
}
cx /= pts.length / 2;
cz /= pts.length / 2;
if (cx < minX || cx > maxX || cz < minZ || cz > maxZ) {
outside++;
continue;
}
if (pts.length / 2 > 255 || pts.length < 6) continue;
const area = polygonArea(pts);
if (area < 12) {
tiny++;
continue;
}
if (blocked(osm, cx, cz)) {
duplicate++;
continue;
}
if (inFactory(cx, cz)) {
factory++;
continue;
}
const height = estimateHeight(area, {inIndustrial: blocked(industrial, cx, cz), inPark: blocked(parks, cx, cz), inOutskirts: outskirts(cx, cz)}, Math.round(cx * 7 + cz * 13));
out.push({height, pts});
if (!line.trim()) continue;
read++;
const ring = JSON.parse(line).geometry.coordinates[0];
const pts = [];
for (const [lon, lat] of ring.slice(0, -1)) {
const p = toLocal(lat, lon);
pts.push(p.x, p.z);
}
let cx = 0,
cz = 0;
for (let i = 0; i < pts.length; i += 2) {
cx += pts[i];
cz += pts[i + 1];
}
cx /= pts.length / 2;
cz /= pts.length / 2;
if (cx < minX || cx > maxX || cz < minZ || cz > maxZ) {
outside++;
continue;
}
if (pts.length / 2 > 255 || pts.length < 6) continue;
const area = polygonArea(pts);
if (area < 12) {
tiny++;
continue;
}
if (blocked(osm, cx, cz)) {
duplicate++;
continue;
}
if (inFactory(cx, cz)) {
factory++;
continue;
}
const height = estimateHeight(
area,
{
inIndustrial: blocked(industrial, cx, cz),
inPark: blocked(parks, cx, cz),
inOutskirts: outskirts(cx, cz),
},
Math.round(cx * 7 + cz * 13),
);
out.push({ height, pts });
}
let bytes = 20;
for (const b of out) bytes += 2 + b.pts.length * 2;
const buffer = new ArrayBuffer(bytes), view = new DataView(buffer);
const buffer = new ArrayBuffer(bytes),
view = new DataView(buffer);
[0x52, 0x4d, 0x42, 0x32].forEach((c, i) => view.setUint8(i, c));
out.sort((a, b) => Math.floor(a.pts[1] / 100) - Math.floor(b.pts[1] / 100) || a.pts[0] - b.pts[0]);
view.setUint32(4, out.length, true);
// Coordinates are stored from a point 200 m outside the region, so edge buildings never go negative.
const originX = minX - 200, originZ = minZ - 200;
const originX = minX - 200,
originZ = minZ - 200;
view.setFloat32(8, originX, true);
view.setFloat32(12, originZ, true);
view.setFloat32(16, STEP, true);
let o = 20;
for (const b of out) {
view.setUint8(o++, b.pts.length / 2);
view.setUint8(o++, Math.min(255, Math.round(b.height / .5)));
let px = 0, pz = 0;
for (let i = 0; i < b.pts.length; i += 2) {
const qx = Math.round((b.pts[i] - originX) / STEP), qz = Math.round((b.pts[i + 1] - originZ) / STEP);
if (i === 0) {
view.setUint16(o, qx, true);
view.setUint16(o + 2, qz, true);
} else {
view.setInt16(o, qx - px, true);
view.setInt16(o + 2, qz - pz, true);
}
px = qx;
pz = qz;
o += 4;
view.setUint8(o++, b.pts.length / 2);
view.setUint8(o++, Math.min(255, Math.round(b.height / 0.5)));
let px = 0,
pz = 0;
for (let i = 0; i < b.pts.length; i += 2) {
const qx = Math.round((b.pts[i] - originX) / STEP),
qz = Math.round((b.pts[i + 1] - originZ) / STEP);
if (i === 0) {
view.setUint16(o, qx, true);
view.setUint16(o + 2, qz, true);
} else {
view.setInt16(o, qx - px, true);
view.setInt16(o + 2, qz - pz, true);
}
px = qx;
pz = qz;
o += 4;
}
}
mkdirSync(new URL('../public/data/', import.meta.url), {recursive: true});
mkdirSync(new URL('../public/data/', import.meta.url), { recursive: true });
writeFileSync(new URL('../public/data/buildings.bin', import.meta.url), new Uint8Array(buffer));
console.log(`read ${read}, kept ${out.length}, outside ${outside}, OSM duplicates ${duplicate}, factory ${factory}, tiny ${tiny}, ${(bytes / 1e6).toFixed(1)} MB`);
console.log(
`read ${read}, kept ${out.length}, outside ${outside}, OSM duplicates ${duplicate}, factory ${factory}, tiny ${tiny}, ${(bytes / 1e6).toFixed(1)} MB`,
);
+248 -141
View File
@@ -14,194 +14,301 @@
// main roads: [bbox:36.298,59.405,36.462,59.572];way["highway"~"^(trunk|primary|secondary|tertiary|motorway)(_link)?$"];
// poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags;
// Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain).
import {readFileSync, writeFileSync} from 'node:fs';
import {ORIGIN, toLocal} from '../src/utils/geo.js';
import { readFileSync, writeFileSync } from 'node:fs';
import { ORIGIN, toLocal } from '../src/utils/geo.js';
const args = {osm: []};
const args = { osm: [] };
for (let i = 2, key = null; i < process.argv.length; i++) {
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
else if (key === 'osm') args.osm.push(process.argv[i]);
else args[key] = process.argv[i];
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
else if (key === 'osm') args.osm.push(process.argv[i]);
else args[key] = process.argv[i];
}
if (!args.osm.length || !args.poi || !args.provinces) {
console.error('usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>');
process.exit(1);
console.error(
'usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>',
);
process.exit(1);
}
// Points of interest are kept only inside the detailed city zone.
const BBOX = {minLat: 36.300, maxLat: 36.330, minLon: 59.512, maxLon: 59.566};
const BBOX = { minLat: 36.3, maxLat: 36.33, minLon: 59.512, maxLon: 59.566 };
// The playable region: from the factory on the Chenaran road to Park Mellat and Ferdowsi University.
const REGION = {minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572};
const REGION = { minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572 };
const merged = new Map();
for (const file of args.osm) for (const e of JSON.parse(readFileSync(file, 'utf8')).elements) merged.set(`${e.type}/${e.id}`, e);
for (const file of args.osm)
for (const e of JSON.parse(readFileSync(file, 'utf8')).elements)
merged.set(`${e.type}/${e.id}`, e);
const city = [...merged.values()];
const poi = JSON.parse(readFileSync(args.poi, 'utf8')).elements;
const provincesPath = args.provinces;
const r1 = v => Math.round(v * 10) / 10;
const flat = geometry => geometry.flatMap(({lat, lon}) => {
const {x, z} = toLocal(lat, lon);
const r1 = (v) => Math.round(v * 10) / 10;
const flat = (geometry) =>
geometry.flatMap(({ lat, lon }) => {
const { x, z } = toLocal(lat, lon);
return [r1(x), r1(z)];
});
});
// Closed OSM rings repeat their first node; the game stores each vertex once.
const ring = geometry => {
const pts = flat(geometry);
if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2;
return pts;
const ring = (geometry) => {
const pts = flat(geometry);
if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2;
return pts;
};
const hash = n => {
n = (n ^ 61) ^ (n >>> 16);
n = Math.imul(n, 9);
n ^= n >>> 4;
n = Math.imul(n, 0x27d4eb2d);
return ((n ^ (n >>> 15)) >>> 0) / 4294967296;
const hash = (n) => {
n = n ^ 61 ^ (n >>> 16);
n = Math.imul(n, 9);
n ^= n >>> 4;
n = Math.imul(n, 0x27d4eb2d);
return ((n ^ (n >>> 15)) >>> 0) / 4294967296;
};
const roadTypes = ['trunk', 'primary', 'secondary', 'tertiary', 'unclassified', 'residential', 'living_street', 'service', 'pedestrian', 'footway', 'cycleway', 'path', 'track', 'steps'];
const names = [], nameIndex = new Map();
const nameId = name => {
if (!name) return -1;
if (!nameIndex.has(name)) {
nameIndex.set(name, names.length);
names.push(name);
}
return nameIndex.get(name);
const roadTypes = [
'trunk',
'primary',
'secondary',
'tertiary',
'unclassified',
'residential',
'living_street',
'service',
'pedestrian',
'footway',
'cycleway',
'path',
'track',
'steps',
];
const names = [],
nameIndex = new Map();
const nameId = (name) => {
if (!name) return -1;
if (!nameIndex.has(name)) {
nameIndex.set(name, names.length);
names.push(name);
}
return nameIndex.get(name);
};
const roads = [], buildings = [], areas = [], labels = [], onewayRoads = [];
const roads = [],
buildings = [],
areas = [],
labels = [],
onewayRoads = [];
const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus', 'industrial', 'farmland'];
const areaKind = t => t.leisure === 'park' || t.leisure === 'garden' || t.landuse === 'park' || t.landuse === 'recreation_ground' ? 'park'
: t.landuse === 'grass' ? 'grass'
: t.landuse === 'forest' || t.natural === 'wood' ? 'forest'
: t.leisure === 'pitch' ? 'pitch'
: t.natural === 'water' ? 'water'
: ['school', 'university', 'college'].includes(t.amenity) ? 'campus'
: t.landuse === 'industrial' ? 'industrial'
: t.landuse === 'farmland' ? 'farmland' : null;
const areaKind = (t) =>
t.leisure === 'park' ||
t.leisure === 'garden' ||
t.landuse === 'park' ||
t.landuse === 'recreation_ground'
? 'park'
: t.landuse === 'grass'
? 'grass'
: t.landuse === 'forest' || t.natural === 'wood'
? 'forest'
: t.leisure === 'pitch'
? 'pitch'
: t.natural === 'water'
? 'water'
: ['school', 'university', 'college'].includes(t.amenity)
? 'campus'
: t.landuse === 'industrial'
? 'industrial'
: t.landuse === 'farmland'
? 'farmland'
: null;
// Joins a relation's outer member ways end to end into closed rings.
function joinRings(members) {
const parts = members.filter(m => m.type === 'way' && m.role !== 'inner' && m.geometry?.length > 1).map(m => m.geometry.slice());
const rings = [], same = (a, b) => a.lat === b.lat && a.lon === b.lon;
while (parts.length) {
let current = parts.shift();
for (let grown = true; grown && !same(current[0], current.at(-1));) {
grown = false;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (same(current.at(-1), p[0])) current = current.concat(p.slice(1));
else if (same(current.at(-1), p.at(-1))) current = current.concat(p.slice(0, -1).reverse());
else continue;
parts.splice(i, 1);
grown = true;
break;
}
}
if (same(current[0], current.at(-1)) && current.length > 3) rings.push(current);
const parts = members
.filter((m) => m.type === 'way' && m.role !== 'inner' && m.geometry?.length > 1)
.map((m) => m.geometry.slice());
const rings = [],
same = (a, b) => a.lat === b.lat && a.lon === b.lon;
while (parts.length) {
let current = parts.shift();
for (let grown = true; grown && !same(current[0], current.at(-1));) {
grown = false;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (same(current.at(-1), p[0])) current = current.concat(p.slice(1));
else if (same(current.at(-1), p.at(-1))) current = current.concat(p.slice(0, -1).reverse());
else continue;
parts.splice(i, 1);
grown = true;
break;
}
}
return rings;
if (same(current[0], current.at(-1)) && current.length > 3) rings.push(current);
}
return rings;
}
for (const e of city) {
const t = e.tags ?? {};
// Motorways are drawn and routed like trunk roads.
const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk');
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
// One-way roads are stored pointing in their direction of travel; dual carriageways
// (each side its own way) are one-way, as are motorways and roundabouts by default.
const oneway = ['yes', 'true', '1', '-1'].includes(t.oneway) || (t.oneway !== 'no' && (t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
// Whole ways come back from Overpass, so cut them where they leave the region.
const inRegion = ({lat, lon}) => lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
let run = [];
for (const point of [...geometry, null]) {
if (point && inRegion(point)) run.push(point);
else {
if (run.length > 1) {
if (oneway) onewayRoads.push(roads.length);
roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]);
}
run = [];
}
const t = e.tags ?? {};
// Motorways are drawn and routed like trunk roads.
const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk');
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
// One-way roads are stored pointing in their direction of travel; dual carriageways
// (each side its own way) are one-way, as are motorways and roundabouts by default.
const oneway =
['yes', 'true', '1', '-1'].includes(t.oneway) ||
(t.oneway !== 'no' &&
(t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
// Whole ways come back from Overpass, so cut them where they leave the region.
const inRegion = ({ lat, lon }) =>
lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
let run = [];
for (const point of [...geometry, null]) {
if (point && inRegion(point)) run.push(point);
else {
if (run.length > 1) {
if (oneway) onewayRoads.push(roads.length);
roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]);
}
} else if (e.type === 'way' && t.building && e.geometry?.length > 3) {
const levels = Number.parseFloat(t['building:levels']), height = Number.parseFloat(t.height);
const h = Number.isFinite(height) ? height : Number.isFinite(levels) ? levels * 3.2 : (2 + Math.floor(hash(e.id) * 4)) * 3.2;
buildings.push([r1(Math.min(h, 80)), ...ring(e.geometry)]);
} else if (e.type === 'way' && areaKind(t) && e.geometry?.length > 3) {
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(e.geometry)]);
} else if (e.type === 'relation' && areaKind(t)) {
for (const r of joinRings(e.members ?? [])) areas.push([areaKinds.indexOf(areaKind(t)), ...ring(r)]);
}
if (t.name && (t.amenity === 'school' || t.amenity === 'university' || t.leisure === 'park' || t.landuse === 'industrial')) {
const c = e.type === 'node' ? e : e.bounds ? {lat: (e.bounds.minlat + e.bounds.maxlat) / 2, lon: (e.bounds.minlon + e.bounds.maxlon) / 2} : null;
if (c) labels.push({name: t.name, kind: t.landuse === 'industrial' ? 'industrial' : t.amenity ?? 'park', ...roundPoint(toLocal(c.lat, c.lon))});
run = [];
}
}
} else if (e.type === 'way' && t.building && e.geometry?.length > 3) {
const levels = Number.parseFloat(t['building:levels']),
height = Number.parseFloat(t.height);
const h = Number.isFinite(height)
? height
: Number.isFinite(levels)
? levels * 3.2
: (2 + Math.floor(hash(e.id) * 4)) * 3.2;
buildings.push([r1(Math.min(h, 80)), ...ring(e.geometry)]);
} else if (e.type === 'way' && areaKind(t) && e.geometry?.length > 3) {
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(e.geometry)]);
} else if (e.type === 'relation' && areaKind(t)) {
for (const r of joinRings(e.members ?? []))
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(r)]);
}
if (
t.name &&
(t.amenity === 'school' ||
t.amenity === 'university' ||
t.leisure === 'park' ||
t.landuse === 'industrial')
) {
const c =
e.type === 'node'
? e
: e.bounds
? {
lat: (e.bounds.minlat + e.bounds.maxlat) / 2,
lon: (e.bounds.minlon + e.bounds.maxlon) / 2,
}
: null;
if (c)
labels.push({
name: t.name,
kind: t.landuse === 'industrial' ? 'industrial' : (t.amenity ?? 'park'),
...roundPoint(toLocal(c.lat, c.lon)),
});
}
}
for (const e of poi) {
const t = e.tags ?? {}, c = e.center ?? e;
if (c.lat < BBOX.minLat || c.lat > BBOX.maxLat || c.lon < BBOX.minLon || c.lon > BBOX.maxLon) continue;
if (t.railway === 'station' || t.shop === 'mall') labels.push({name: t.name, kind: t.railway ? 'metro' : 'mall', ...roundPoint(toLocal(c.lat, c.lon))});
const t = e.tags ?? {},
c = e.center ?? e;
if (c.lat < BBOX.minLat || c.lat > BBOX.maxLat || c.lon < BBOX.minLon || c.lon > BBOX.maxLon)
continue;
if (t.railway === 'station' || t.shop === 'mall')
labels.push({
name: t.name,
kind: t.railway ? 'metro' : 'mall',
...roundPoint(toLocal(c.lat, c.lon)),
});
}
function roundPoint({x, z}) {
return {x: r1(x), z: r1(z)};
function roundPoint({ x, z }) {
return { x: r1(x), z: r1(z) };
}
// Areas draw largest first, so small pitches and ponds stay visible on top of parks.
const polygonArea = pts => {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return Math.abs(a / 2);
const polygonArea = (pts) => {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return Math.abs(a / 2);
};
areas.sort((a, b) => polygonArea(b.slice(1)) - polygonArea(a.slice(1)));
// The world spans every road we kept, plus a margin.
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
for (const road of roads) for (let i = 2; i < road.length; i += 2) {
let minX = Infinity,
minZ = Infinity,
maxX = -Infinity,
maxZ = -Infinity;
for (const road of roads)
for (let i = 2; i < road.length; i += 2) {
minX = Math.min(minX, road[i]);
maxX = Math.max(maxX, road[i]);
minZ = Math.min(minZ, road[i + 1]);
maxZ = Math.max(maxZ, road[i + 1]);
}
const min = {x: Math.floor(minX - 200), z: Math.floor(minZ - 200)}, max = {x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200)};
}
const min = { x: Math.floor(minX - 200), z: Math.floor(minZ - 200) },
max = { x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200) };
const city_ = {
origin: ORIGIN,
attribution: '© OpenStreetMap contributors',
bounds: {minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z},
roadTypes, areaKinds, names, roads, buildings, areas,
// Indices into `roads` of one-way roads, whose points run in the direction of travel.
oneway: onewayRoads,
labels: [...new Map(labels.map(l => [l.name, l])).values()]
origin: ORIGIN,
attribution: '© OpenStreetMap contributors',
bounds: { minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z },
roadTypes,
areaKinds,
names,
roads,
buildings,
areas,
// Indices into `roads` of one-way roads, whose points run in the direction of travel.
oneway: onewayRoads,
labels: [...new Map(labels.map((l) => [l.name, l])).values()],
};
writeFileSync(new URL('../src/data/mellat.json', import.meta.url), JSON.stringify(city_));
// Douglas–Peucker keeps the province shapes recognisable at a fraction of the size.
function simplify(points, tolerance) {
if (points.length < 3) return points;
const [ax, ay] = points[0], [bx, by] = points.at(-1);
let index = 0, distance = 0;
for (let i = 1; i < points.length - 1; i++) {
const [px, py] = points[i], dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy);
// A closed ring starts and ends on the same point, so measure from that point instead.
const d = len ? Math.abs(dy * px - dx * py + bx * ay - by * ax) / len : Math.hypot(px - ax, py - ay);
if (d > distance) {
index = i;
distance = d;
}
if (points.length < 3) return points;
const [ax, ay] = points[0],
[bx, by] = points.at(-1);
let index = 0,
distance = 0;
for (let i = 1; i < points.length - 1; i++) {
const [px, py] = points[i],
dx = bx - ax,
dy = by - ay,
len = Math.hypot(dx, dy);
// A closed ring starts and ends on the same point, so measure from that point instead.
const d = len
? Math.abs(dy * px - dx * py + bx * ay - by * ax) / len
: Math.hypot(px - ax, py - ay);
if (d > distance) {
index = i;
distance = d;
}
if (distance <= tolerance) return [points[0], points.at(-1)];
return [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)];
}
if (distance <= tolerance) return [points[0], points.at(-1)];
return [
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
...simplify(points.slice(index), tolerance),
];
}
const provinces = JSON.parse(readFileSync(provincesPath, 'utf8')).features
.filter(f => f.properties.adm0_a3 === 'IRN')
.map(f => {
const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
return {
id: f.properties.name,
name: f.properties.name_fa.replace(/^استان\s+/, ''),
rings: polygons.map(p => simplify(p[0], .02).flatMap(([lon, lat]) => [Math.round(lon * 1000) / 1000, Math.round(lat * 1000) / 1000])).filter(r => r.length >= 8)
};
});
writeFileSync(new URL('../src/data/iran.json', import.meta.url), JSON.stringify({provinces}));
console.log(`roads ${roads.length}, buildings ${buildings.length}, areas ${areas.length}, labels ${city_.labels.length}, provinces ${provinces.length}`);
const provinces = JSON.parse(readFileSync(provincesPath, 'utf8'))
.features.filter((f) => f.properties.adm0_a3 === 'IRN')
.map((f) => {
const polygons =
f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
return {
id: f.properties.name,
name: f.properties.name_fa.replace(/^استان\s+/, ''),
rings: polygons
.map((p) =>
simplify(p[0], 0.02).flatMap(([lon, lat]) => [
Math.round(lon * 1000) / 1000,
Math.round(lat * 1000) / 1000,
]),
)
.filter((r) => r.length >= 8),
};
});
writeFileSync(new URL('../src/data/iran.json', import.meta.url), JSON.stringify({ provinces }));
console.log(
`roads ${roads.length}, buildings ${buildings.length}, areas ${areas.length}, labels ${city_.labels.length}, provinces ${provinces.length}`,
);
+65 -46
View File
@@ -1,3 +1,4 @@
import { downloadImagery } from '../src/services/imageryDownloadService.js';
// Downloads Esri World Imagery tiles for the game region into public/imagery/<z>/<x>/<y>.jpg and
// writes public/imagery/index.json listing them.
//
@@ -9,69 +10,87 @@
//
// Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community. Commercial use and
// hosting copies require a suitable Esri licence; check Esri's terms for your use.
import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs';
import {toLatLon} from '../src/utils/geo.js';
import {buildGraph, routeBetween} from '../src/game/world/routing.js';
import {FACTORY, machineById} from '../src/constants/locations.js';
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { toLatLon } from '../src/utils/geo.js';
import { buildGraph, routeBetween } from '../src/game/world/routing.js';
import { FACTORY, machineById } from '../src/constants/locations.js';
const URL_TEMPLATE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
const URL_TEMPLATE =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
const OUT = new URL('../public/imagery/', import.meta.url);
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
export const tileX = (lon, z) => Math.floor((lon + 180) / 360 * 2 ** z);
export const tileY = (lat, z) => Math.floor((1 - Math.asinh(Math.tan(lat * Math.PI / 180)) / Math.PI) / 2 * 2 ** z);
export const tileX = (lon, z) => Math.floor(((lon + 180) / 360) * 2 ** z);
export const tileY = (lat, z) =>
Math.floor(((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * 2 ** z);
const tiles = {15: new Set(), 17: new Set()};
const tiles = { 15: new Set(), 17: new Set() };
const addBox = (z, lat0, lon0, lat1, lon1) => {
for (let x = tileX(lon0, z); x <= tileX(lon1, z); x++)
for (let y = tileY(lat1, z); y <= tileY(lat0, z); y++) tiles[z].add(`${x}/${y}`);
for (let x = tileX(lon0, z); x <= tileX(lon1, z); x++)
for (let y = tileY(lat1, z); y <= tileY(lat0, z); y++) tiles[z].add(`${x}/${y}`);
};
const {minX, minZ, maxX, maxZ} = data.bounds;
const nw = toLatLon(minX, minZ), se = toLatLon(maxX, maxZ);
const { minX, minZ, maxX, maxZ } = data.bounds;
const nw = toLatLon(minX, minZ),
se = toLatLon(maxX, maxZ);
addBox(15, se.lat, nw.lon, nw.lat, se.lon);
addBox(17, 36.300, 59.512, 36.330, 59.566);
addBox(17, 36.3, 59.512, 36.33, 59.566);
addBox(17, 36.443, 59.414, 36.458, 59.434);
// The drive along the real roads, widened by 300 m.
const drive = buildGraph(data.roads, {drivable: true}), park = machineById('mellat-park').anchor;
const drive = buildGraph(data.roads, { drivable: true }),
park = machineById('mellat-park').anchor;
const route = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, park.x, park.z);
for (let i = 1; i < route.points.length; i++) {
const a = route.points[i - 1], b = route.points[i], steps = Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / 100);
for (let s = 0; s <= steps; s++) {
const x = a.x + (b.x - a.x) * s / steps, z = a.z + (b.z - a.z) * s / steps;
const p0 = toLatLon(x - 300, z + 300), p1 = toLatLon(x + 300, z - 300);
addBox(17, p0.lat, p0.lon, p1.lat, p1.lon);
}
const a = route.points[i - 1],
b = route.points[i],
steps = Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / 100);
for (let s = 0; s <= steps; s++) {
const x = a.x + ((b.x - a.x) * s) / steps,
z = a.z + ((b.z - a.z) * s) / steps;
const p0 = toLatLon(x - 300, z + 300),
p1 = toLatLon(x + 300, z - 300);
addBox(17, p0.lat, p0.lon, p1.lat, p1.lon);
}
}
const queue = Object.entries(tiles).flatMap(([z, set]) => [...set].map(key => ({z: Number(z), key})));
const queue = Object.entries(tiles).flatMap(([z, set]) =>
[...set].map((key) => ({ z: Number(z), key })),
);
console.log(`tiles: zoom 15 ${tiles[15].size}, zoom 17 ${tiles[17].size}`);
let done = 0, fetched = 0, bytes = 0;
let done = 0,
fetched = 0,
bytes = 0;
async function worker() {
while (queue.length) {
const {z, key} = queue.pop(), [x, y] = key.split('/');
const file = new URL(`${z}/${x}/${y}.jpg`, OUT);
if (!existsSync(file)) {
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x), {headers: {'User-Agent': 'robot-market-game/1.0'}});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = new Uint8Array(await res.arrayBuffer());
mkdirSync(new URL(`${z}/${x}/`, OUT), {recursive: true});
writeFileSync(file, body);
fetched++;
bytes += body.length;
break;
} catch (error) {
if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`);
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
while (queue.length) {
const { z, key } = queue.pop(),
[x, y] = key.split('/');
const file = new URL(`${z}/${x}/${y}.jpg`, OUT);
if (!existsSync(file)) {
for (let attempt = 0; attempt < 4; attempt++) {
try {
const body = new Uint8Array(
await downloadImagery(
URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x),
),
);
mkdirSync(new URL(`${z}/${x}/`, OUT), { recursive: true });
writeFileSync(file, body);
fetched++;
bytes += body.length;
break;
} catch (error) {
if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`);
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
if (++done % 100 === 0) console.log(`${done} tiles…`);
}
}
if (++done % 100 === 0) console.log(`${done} tiles…`);
}
}
await Promise.all(Array.from({length: 6}, worker));
const index = {attribution: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community'};
for (const [z, set] of Object.entries(tiles)) index[z] = [...set].filter(key => existsSync(new URL(`${z}/${key}.jpg`, OUT))).sort();
await Promise.all(Array.from({ length: 6 }, worker));
const index = { attribution: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community' };
for (const [z, set] of Object.entries(tiles))
index[z] = [...set].filter((key) => existsSync(new URL(`${z}/${key}.jpg`, OUT))).sort();
writeFileSync(new URL('index.json', OUT), JSON.stringify(index));
console.log(`downloaded ${fetched} new tiles (${(bytes / 1e6).toFixed(1)} MB); index lists ${index[15].length} + ${index[17].length}`);
console.log(
`downloaded ${fetched} new tiles (${(bytes / 1e6).toFixed(1)} MB); index lists ${index[15].length} + ${index[17].length}`,
);