refactor: separate React views, Redux snapshots and Three.js runtime

This commit is contained in:
2026-10-28 12:26:40 +03:30
parent 6bd9b17173
commit d0da416923
77 changed files with 4941 additions and 3723 deletions
+462
View File
@@ -0,0 +1,462 @@
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
const SCALE = 0.75; // tile pixels per metre
const TILE_PX = 512,
TILE_M = TILE_PX / SCALE,
MAX_TILES = 48;
const OVERVIEW_SCALE = 0.07;
const AREA = {
campus: '#28322c',
park: '#2c4a32',
grass: '#30503a',
forest: '#27432e',
pitch: '#33603c',
water: '#2b5b6e',
industrial: '#262c29',
farmland: '#2a3326',
};
const ROAD_WIDTH = [11, 10, 9, 8, 6.5, 6, 5, 4, 4, 2.4, 2, 1.6, 3, 2.2];
const roadColor = (type) => (type <= 2 ? '#d7dcd3' : type <= 7 ? '#8e9891' : '#5f6b63');
const tileKey = (tx, tz) => tx * 100003 + tz;
function path(c, pts) {
c.beginPath();
c.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
}
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
function drawFeatures(c, { areas, roads, buildings }, data, minWidth) {
for (const area of areas) {
c.fillStyle = AREA[data.areaKinds[area[0]]];
path(c, area.slice(1));
c.fill();
}
c.lineCap = c.lineJoin = 'round';
for (const road of roads) {
c.strokeStyle = roadColor(road[0]);
c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
path(c, road.slice(2));
c.stroke();
}
c.fillStyle = '#3c4943';
for (const b of buildings) {
path(c, b.slice(1));
c.fill();
}
}
export function createMapAssets(data, extraLabels = []) {
const { minX, minZ, maxX, maxZ } = data.bounds;
// Bin every feature into the tiles its bounding box touches.
const bins = new Map();
const bin = (list, feature, first) => {
let x0 = Infinity,
z0 = Infinity,
x1 = -Infinity,
z1 = -Infinity;
for (let i = first; i < feature.length; i += 2) {
x0 = Math.min(x0, feature[i]);
x1 = Math.max(x1, feature[i]);
z0 = Math.min(z0, feature[i + 1]);
z1 = Math.max(z1, feature[i + 1]);
}
for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
const k = tileKey(tx, tz);
if (!bins.has(k)) bins.set(k, { areas: [], roads: [], buildings: [] });
bins.get(k)[list].push(feature);
}
};
data.areas.forEach((a) => bin('areas', a, 1));
[...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
data.buildings.forEach((b) => bin('buildings', b, 1));
const cache = new Map();
function tile(tx, tz) {
const k = tileKey(tx, tz);
if (cache.has(k)) {
const canvas = cache.get(k);
cache.delete(k);
cache.set(k, canvas);
return canvas;
}
const canvas = document.createElement('canvas');
canvas.width = canvas.height = TILE_PX;
const c = canvas.getContext('2d');
c.fillStyle = '#1b231f';
c.fillRect(0, 0, TILE_PX, TILE_PX);
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
drawFeatures(c, bins.get(k) ?? { areas: [], roads: [], buildings: [] }, data, 1.6);
cache.set(k, canvas);
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
return canvas;
}
const overview = document.createElement('canvas');
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
const o = overview.getContext('2d');
o.fillStyle = '#1b231f';
o.fillRect(0, 0, overview.width, overview.height);
o.setTransform(
OVERVIEW_SCALE,
0,
0,
OVERVIEW_SCALE,
-minX * OVERVIEW_SCALE,
-minZ * OVERVIEW_SCALE,
);
drawFeatures(
o,
{
areas: data.areas,
roads: data.roads.filter((r) => r[0] <= 5).sort((a, b) => b[0] - a[0]),
buildings: [],
},
data,
14,
);
// One label per named boulevard, at the middle of its longest way.
const streets = new Map();
for (const road of data.roads) {
if (road[1] < 0 || road[0] > 3) continue;
let len = 0;
for (let i = 4; i < road.length; i += 2)
len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
const name = data.names[road[1]];
if ((streets.get(name)?.len ?? 0) < len) {
const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
const angle = Math.atan2(
road[Math.min(mid + 3, road.length - 1)] - road[mid - 1],
road[Math.min(mid + 2, road.length - 2)] - road[mid - 2],
);
streets.set(name, { name, len, x: road[mid], z: road[mid + 1], angle });
}
}
return {
tile,
overview,
bounds: data.bounds,
labels: [...data.labels, ...extraLabels],
streets: [...streets.values()].filter((s) => s.len > 150),
};
}
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
// metres; pxPerMetre decides between detailed tiles and the overview.
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
const { minX, minZ } = assets.bounds;
c.fillStyle = '#141b18';
c.fillRect(x0, z0, x1 - x0, z1 - z0);
if (pxPerMetre < 0.22) {
c.drawImage(
assets.overview,
minX,
minZ,
assets.overview.width / OVERVIEW_SCALE,
assets.overview.height / OVERVIEW_SCALE,
);
return;
}
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + 0.5, TILE_M + 0.5);
}
function rotatePoint(x, z, cx, cz, angle, k) {
const dx = (x - cx) * k,
dz = (z - cz) * k,
cos = Math.cos(angle),
sin = Math.sin(angle);
return [dx * cos - dz * sin, dx * sin + dz * cos];
}
function machineIcon(c, x, y, { objective, visited, open }) {
c.save();
c.translate(x, y);
if (objective) {
c.rotate(Math.PI / 4);
c.fillStyle = '#f2c94c';
c.strokeStyle = '#1b1406';
c.lineWidth = 2;
c.fillRect(-7, -7, 14, 14);
c.strokeRect(-7, -7, 14, 14);
} else {
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
c.strokeStyle = '#08201c';
c.lineWidth = 2;
c.beginPath();
c.roundRect(-6, -8, 12, 16, 3);
c.fill();
c.stroke();
c.fillStyle = '#08201c';
c.fillRect(-3, -5, 6, 6);
}
c.restore();
if (visited) {
c.fillStyle = '#fff';
c.font = 'bold 11px sans-serif';
c.fillText('✓', x + 8, y - 6);
}
}
// Circular minimap rotated so the camera's forward direction points up.
export function drawMinimap(ctx, assets, view) {
const { size, x, z, heading, camForward, radius, markers, route } = view;
const r = size / 2,
k = r / radius,
angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.beginPath();
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#141b18';
ctx.fillRect(0, 0, size, size);
ctx.translate(r, r);
ctx.save();
ctx.rotate(angle);
ctx.scale(k, k);
ctx.translate(-x, -z);
paintWorld(
ctx,
assets,
x - radius * 1.5,
z - radius * 1.5,
x + radius * 1.5,
z + radius * 1.5,
k,
);
ctx.restore();
if (route?.length > 1) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 3;
ctx.setLineDash([6, 4]);
ctx.beginPath();
route.forEach((p, i) => {
const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
});
ctx.stroke();
ctx.setLineDash([]);
}
for (const m of markers) {
let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
const d = Math.hypot(px, py);
if (d > r - 12) {
if (!m.objective) continue;
px *= (r - 12) / d;
py *= (r - 12) / d;
}
machineIcon(ctx, px, py, m);
}
// Player arrow, then north marker on the rim.
ctx.save();
ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#0b100e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -10);
ctx.lineTo(7, 8);
ctx.lineTo(0, 4);
ctx.lineTo(-7, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
const nl = Math.hypot(nx, ny) || 1;
ctx.fillStyle = '#f2c94c';
ctx.font = 'bold 13px Vazirmatn, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
ctx.restore();
ctx.strokeStyle = 'rgba(230,240,235,.55)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
ctx.stroke();
}
// Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel).
export function drawFullMap(ctx, assets, view, state) {
const { width, height } = ctx.canvas,
dpr = state.dpr,
w = width / dpr,
h = height / dpr;
const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#101614';
ctx.fillRect(0, 0, w, h);
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.scale(1 / view.mpp, 1 / view.mpp);
ctx.translate(-view.cx, -view.cz);
paintWorld(
ctx,
assets,
view.cx - (w / 2) * view.mpp,
view.cz - (h / 2) * view.mpp,
view.cx + (w / 2) * view.mpp,
view.cz + (h / 2) * view.mpp,
1 / view.mpp,
);
ctx.restore();
// Grid lines every 500 m, like a tactical map.
ctx.strokeStyle = 'rgba(160,200,185,.08)';
ctx.lineWidth = 1;
for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
const [sx] = toScreen(gx, 0);
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx, h);
ctx.stroke();
}
for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
const [, sy] = toScreen(0, gz);
ctx.beginPath();
ctx.moveTo(0, sy);
ctx.lineTo(w, sy);
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (view.mpp < 3) {
ctx.font = '11px Vazirmatn, sans-serif';
ctx.fillStyle = 'rgba(235,240,232,.75)';
for (const s of assets.streets) {
const [sx, sy] = toScreen(s.x, s.z);
if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
ctx.save();
ctx.translate(sx, sy);
let a = s.angle;
if (a > Math.PI / 2) a -= Math.PI;
if (a < -Math.PI / 2) a += Math.PI;
ctx.rotate(a);
ctx.fillText(s.name, 0, 0);
ctx.restore();
}
}
for (const l of assets.labels) {
if (l.kind === 'school' && view.mpp > 1.6) continue;
const [sx, sy] = toScreen(l.x, l.z);
ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
if (l.kind === 'industrial' && view.mpp > 6) continue;
ctx.fillStyle = {
park: '#9fd49a',
university: '#c9b6f2',
school: '#e8c79a',
metro: '#ff8a80',
mall: '#f5b8d9',
industrial: '#a9b2ad',
factory: '#3ee0c9',
}[l.kind];
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
ctx.fillText(
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
sx,
sy + 14,
);
}
if (state.route?.length > 1) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 4;
ctx.setLineDash([8, 5]);
ctx.beginPath();
state.route.forEach((p, i) => {
const [sx, sy] = toScreen(p.x, p.z);
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
});
ctx.stroke();
ctx.setLineDash([]);
}
for (const m of state.markers) {
const [sx, sy] = toScreen(m.x, m.z);
if (m.id === state.selected) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
ctx.stroke();
}
machineIcon(ctx, sx, sy, m);
}
const [px, py] = toScreen(state.player.x, state.player.z);
ctx.save();
ctx.translate(px, py);
ctx.rotate(
Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2,
);
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -12);
ctx.lineTo(8, 9);
ctx.lineTo(0, 5);
ctx.lineTo(-8, 9);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
return toScreen;
}
// Iran overview for the briefing and the map's country tab. `t` animates the highlight.
export function drawIran(
ctx,
iran,
{ width, height, t = 0, highlight = 'Razavi Khorasan', cities = [] },
) {
const lon0 = 43.8,
lon1 = 63.6,
lat0 = 24.8,
lat1 = 40;
const kx = 0.84,
k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * 0.94;
const offX = (width - (lon1 - lon0) * kx * k) / 2,
offY = (height - (lat1 - lat0) * k) / 2;
const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
ctx.clearRect(0, 0, width, height);
for (const province of iran.provinces) {
const active = province.id === highlight;
ctx.fillStyle = active
? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
: 'rgba(40,56,50,.85)';
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
ctx.lineWidth = active ? 2 : 1;
for (const ring of province.rings) {
ctx.beginPath();
for (let i = 0; i < ring.length; i += 2) {
const [x, y] = p(ring[i], ring[i + 1]);
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
ctx.textAlign = 'center';
for (const city of cities) {
const [x, y] = p(city.lon, city.lat);
if (city.active) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 6 + ((t * 18) % 18), 0, Math.PI * 2);
ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
ctx.beginPath();
ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
ctx.fill();
ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
}
}