Satellite ground and real building footprints
CI/CD / test (push) Successful in 18s
CI/CD / publish (push) Successful in 22s
CI/CD / deploy (push) Successful in 6s

- The ground is Esri World Imagery: ~1 m/pixel tiles around the city
  zone, the factory and 300 m either side of the drive between them,
  coarser tiles for the whole region. Tiles are hosted with the game
  (public/imagery, scripts/fetch-imagery.mjs), stream in near the camera,
  are cached by the service worker as visited, and replace the drawn
  roads and land areas
- ~141,500 Microsoft building footprints (ODbL) join OSM's ~4,000 in a
  compact delta-encoded file (public/data/buildings.bin, built by
  scripts/build-buildings.mjs). Heights are estimated from footprint
  area and land use, with industrial halls outside the ring road.
  Buildings are built in 500 m blocks as the camera approaches
- Caddy compresses the buildings file and caches map data for a week;
  the build targets ES2022 for top-level await
- Tests for decoding, factory clearance, machine reachability and tile
  placement

Imagery attribution: Esri, Maxar, Earthstar Geographics, and the GIS
User Community. Commercial use requires a suitable Esri licence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 09:30:02 +03:30
co-authored by Claude Opus 5.5
parent 5cea802b97
commit 9b289f76b0
1012 changed files with 529 additions and 41 deletions
+150
View File
@@ -0,0 +1,150 @@
// Adds Microsoft's ML building footprints to the game world as public/data/buildings.bin.
//
// Usage:
// node scripts/build-buildings.mjs <global-buildings quadkey file .csv.gz>
//
// The file comes from https://minedbuildings.z5.web.core.windows.net/global-buildings/dataset-links.csv
// (row "Iran,123012100" covers Mashhad). Footprints © Microsoft, ODbL.
//
// OSM buildings in src/data/mellat.json stay as they are; a Microsoft footprint whose centre lies
// inside an OSM building is dropped, as is anything inside the factory compound (drawn by the game).
// Microsoft has no heights for Iran, so they are estimated from footprint area and land use.
//
// Binary layout (little-endian): "RMB2", count u32, origin x f32, origin z f32, step f32, then per building:
// 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/geo.js';
import {FACTORY} from '../src/locations.js';
import {createCollisionWorld, blocked} from '../src/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);
}
const STEP = .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')]);
// 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 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 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
// estates and villages, not apartment blocks.
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;
}
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())});
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});
}
let bytes = 20;
for (const b of out) bytes += 2 + b.pts.length * 2;
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;
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;
}
}
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`);
+77
View File
@@ -0,0 +1,77 @@
// Downloads Esri World Imagery tiles for the game region into public/imagery/<z>/<x>/<y>.jpg and
// writes public/imagery/index.json listing them.
//
// Usage: node scripts/fetch-imagery.mjs
//
// zoom 17 (~1 m per pixel): the city zone, the factory zone, and 300 m either side of the drive
// from the factory to Park Mellat, where the player spends their time;
// zoom 15 (~4 m per pixel): the whole region, seen in the distance and from the highway.
//
// 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/geo.js';
import {buildGraph, routeBetween} from '../src/routing.js';
import {FACTORY, machineById} from '../src/locations.js';
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);
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}`);
};
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.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 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 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;
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)));
}
}
}
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();
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}`);