SOURCE / PINNED RELEASE
Made of little things.
City Breaker
- Release
- 94d2b8664b10…
- Author-recorded commit
- df23103cbcc1…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-cf8b858653e
Archive hash verified: eeeed725668c1ece…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Painted theater: farmland, forests, river, roads, rail and a city of individual
// buildings. Everything is drawn in km with a canvas transform, so the same
// painters render the mission ground, map thumbnails and recon photos.
import { DISTRICTS, HILLS, RIVER, SUPPLY_ROUTES, type DistrictDef } from '../game/map';
export type Rnd = () => number;
export function rng(seed: number): Rnd {
let s = seed >>> 0 || 1;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const PAL = {
fields: ['#6d7247', '#7b7a4b', '#867a50', '#5f6a43', '#8e8656', '#727b4f', '#7f8a58', '#94875a'],
hedge: 'rgba(40,48,28,0.55)',
forest: '#3a4a2e',
forestDark: '#2c3a23',
urban: '#7d7c74',
street: '#9a978c',
road: '#b9b3a0',
rail: '#3b3934',
water: '#2c5a77',
waterLight: '#4c86a8',
bank: '#a59a72',
concrete: '#c4bfb3',
roofRed: '#9c4b37',
roofBrown: '#7a4a35',
roofGray: '#8c8f8f',
roofDark: '#4b4e50',
shadow: 'rgba(0,0,0,0.32)',
};
type C = CanvasRenderingContext2D;
function rect(ctx: C, x: number, y: number, w: number, h: number, fill: string, shadow = true): void {
if (shadow) {
ctx.fillStyle = PAL.shadow;
ctx.fillRect(x + w * 0.08 + 0.03, y + h * 0.08 + 0.03, w, h);
}
ctx.fillStyle = fill;
ctx.fillRect(x, y, w, h);
}
function disc(ctx: C, x: number, y: number, r: number, fill: string, shadow = true): void {
if (shadow) {
ctx.fillStyle = PAL.shadow;
ctx.beginPath();
ctx.arc(x + r * 0.25, y + r * 0.25, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
function polyline(ctx: C, pts: [number, number][], width: number, color: string, dash: number[] = []): void {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dash);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.stroke();
ctx.setLineDash([]);
}
// ── Ground ─────────────────────────────────────────────────────────────────
function hash(i: number, j: number, seed: number): number {
let h = (i * 374761393 + j * 668265263 + seed * 2246822519) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return (h ^ (h >>> 16)) >>> 0;
}
/** Farmland, forests and relief. Deterministic per world position, so tiles join seamlessly. */
export function paintGround(ctx: C, x0: number, y0: number, x1: number, y1: number, seed: number, detail = 1): void {
ctx.fillStyle = '#6a7046';
ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
if (x0 < -80) {
ctx.fillStyle = '#244b66';
ctx.fillRect(x0, y0, -80 - x0, y1 - y0);
}
// Patchwork fields on a jittered global grid.
const cell = 2.2 / detail;
for (let gj = Math.floor(y0 / cell) - 1; gj * cell < y1 + cell; gj++) {
for (let gi = Math.floor(Math.max(x0, -80) / cell) - 1; gi * cell < x1 + cell; gi++) {
const r = rng(hash(gi, gj, seed));
const x = gi * cell + r() * cell;
const y = gj * cell + r() * cell;
if (x < -80) continue;
const w = cell * (0.8 + r() * 0.9);
const h = cell * (0.6 + r() * 0.8);
const a = Math.sin(x * 0.03 + y * 0.02) * 0.6 + (r() - 0.5) * 0.3;
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.fillStyle = PAL.fields[Math.floor(r() * PAL.fields.length)];
ctx.fillRect(-w / 2, -h / 2, w, h);
if (detail > 1.5 && r() < 0.55) {
ctx.strokeStyle = 'rgba(0,0,0,0.08)';
ctx.lineWidth = 0.04;
for (let f = -w / 2; f < w / 2; f += 0.16) {
ctx.beginPath();
ctx.moveTo(f, -h / 2);
ctx.lineTo(f, h / 2);
ctx.stroke();
}
}
ctx.strokeStyle = PAL.hedge;
ctx.lineWidth = 0.06;
ctx.strokeRect(-w / 2, -h / 2, w, h);
ctx.restore();
}
}
// Hills: soft relief shading and forest on the slopes.
HILLS.forEach(([hx, hy, hr], k) => {
if (hx + hr < x0 || hx - hr > x1 || hy + hr < y0 || hy - hr > y1) return;
const g = ctx.createRadialGradient(hx - hr * 0.3, hy - hr * 0.3, 0, hx, hy, hr);
g.addColorStop(0, 'rgba(255,250,220,0.16)');
g.addColorStop(0.55, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.16)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(hx, hy, hr, 0, Math.PI * 2);
ctx.fill();
const r = rng(seed * 31 + k);
const n = Math.round(hr * hr * 2.6);
for (let i = 0; i < n; i++) {
const a = r() * Math.PI * 2;
const d = Math.sqrt(r()) * hr * 0.9;
const rad = 0.25 + r() * 0.35;
const dark = r() < 0.5;
const fx = hx + Math.cos(a) * d;
const fy = hy + Math.sin(a) * d;
if (fx < x0 - 1 || fx > x1 + 1 || fy < y0 - 1 || fy > y1 + 1) continue;
ctx.fillStyle = dark ? PAL.forest : PAL.forestDark;
ctx.beginPath();
ctx.arc(fx, fy, rad, 0, Math.PI * 2);
ctx.fill();
}
});
// Scattered woods, fixed across the whole theater.
const r = rng(seed * 7 + 77);
for (let i = 0; i < 700; i++) {
const cx = -80 + r() * 225;
const cy = -100 + r() * 200;
const blobs: [number, number, number, boolean][] = [];
for (let j = 0; j < 8; j++) blobs.push([cx + (r() - 0.5) * 2, cy + (r() - 0.5) * 2, 0.25 + r() * 0.3, r() < 0.5]);
if (Math.hypot(cx, cy) < 20 || cx + 2 < x0 || cx - 2 > x1 || cy + 2 < y0 || cy - 2 > y1) continue;
for (const [bx, by, br, dark] of blobs) {
ctx.fillStyle = dark ? PAL.forest : PAL.forestDark;
ctx.beginPath();
ctx.arc(bx, by, br, 0, Math.PI * 2);
ctx.fill();
}
}
}
export function paintWater(ctx: C): void {
// Banks, river, highlights.
polyline(ctx, RIVER, 1.5, PAL.bank);
polyline(ctx, RIVER, 1.05, PAL.water);
polyline(ctx, RIVER.map(([x, y]) => [x, y - 0.18] as [number, number]), 0.12, PAL.waterLight);
// Reservoir behind the dam (upstream, east).
ctx.fillStyle = PAL.water;
ctx.beginPath();
ctx.ellipse(62, -7.5, 6, 2.6, -0.1, 0, Math.PI * 2);
ctx.fill();
}
export function paintNetworks(ctx: C, seed: number): void {
const r = rng(seed + 5);
// Country roads radiating from the city.
for (let i = 0; i < 14; i++) {
const a = (i / 14) * Math.PI * 2 + r() * 0.2;
const pts: [number, number][] = [];
let x = Math.cos(a) * 8;
let y = Math.sin(a) * 8;
for (let k = 0; k < 14; k++) {
pts.push([x, y]);
const step = 6 + r() * 3;
const da = a + (r() - 0.5) * 0.5;
x += Math.cos(da) * step;
y += Math.sin(da) * step;
}
polyline(ctx, pts, 0.32, 'rgba(70,64,50,0.6)');
polyline(ctx, pts, 0.2, PAL.road);
}
// Ring road.
const ring: [number, number][] = [];
for (let i = 0; i <= 40; i++) {
const a = (i / 40) * Math.PI * 2;
ring.push([Math.cos(a) * 19 - 2, Math.sin(a) * 15]);
}
polyline(ctx, ring, 0.4, 'rgba(70,64,50,0.6)');
polyline(ctx, ring, 0.26, PAL.road);
// Supply routes on top.
for (const route of SUPPLY_ROUTES) {
if (route.mode === 'river') continue;
if (route.mode === 'rail') {
polyline(ctx, route.points, 0.42, '#6b6252');
polyline(ctx, route.points, 0.22, PAL.rail);
polyline(ctx, route.points, 0.36, 'rgba(30,30,30,0.6)', [0.05, 0.18]);
} else {
polyline(ctx, route.points, 0.5, 'rgba(70,64,50,0.7)');
polyline(ctx, route.points, 0.34, '#cfc7ae');
polyline(ctx, route.points, 0.03, '#f2e8b8', [0.4, 0.4]);
}
}
}
/** Villages along the roads and suburban sprawl around the city. */
export function paintSettlements(ctx: C, seed: number): void {
const r = rng(seed + 9);
// Suburbs: street grids with small houses.
for (let i = 0; i < 1800; i++) {
const a = r() * Math.PI * 2;
const d = 6 + Math.sqrt(r()) * 13;
const x = Math.cos(a) * d * 1.15 - 2;
const y = Math.sin(a) * d * 0.85;
if (DISTRICTS.some((q) => Math.abs(x - q.x) < q.w / 2 + 0.4 && Math.abs(y - q.y) < q.h / 2 + 0.4)) continue;
if (Math.abs(y - riverAt(x)) < 1.1) continue;
const s = 0.12 + r() * 0.14;
rect(ctx, x, y, s * 1.4, s, r() < 0.6 ? PAL.roofRed : PAL.roofGray, true);
}
// Streets in the inner city.
ctx.strokeStyle = 'rgba(150,146,136,0.55)';
ctx.lineWidth = 0.08;
for (let x = -20; x <= 18; x += 1.2) {
ctx.beginPath();
ctx.moveTo(x, -14);
ctx.lineTo(x + 1.5, 14);
ctx.stroke();
}
for (let y = -14; y <= 14; y += 1.2) {
ctx.beginPath();
ctx.moveTo(-20, y);
ctx.lineTo(18, y - 1);
ctx.stroke();
}
// Villages.
for (let v = 0; v < 70; v++) {
const vx = -75 + r() * 210;
const vy = -90 + r() * 180;
if (Math.hypot(vx + 2, vy) < 24 || vx < -80) continue;
for (let i = 0; i < 14; i++) rect(ctx, vx + (r() - 0.5) * 2, vy + (r() - 0.5) * 2, 0.22, 0.16, r() < 0.7 ? PAL.roofRed : PAL.roofGray);
disc(ctx, vx, vy, 0.12, '#d8d2c0');
}
}
function riverAt(x: number): number {
for (let i = 0; i < RIVER.length - 1; i++) {
const [x1, y1] = RIVER[i];
const [x2, y2] = RIVER[i + 1];
if ((x <= x1 && x >= x2) || (x >= x1 && x <= x2)) return y1 + ((y2 - y1) * (x - x1)) / (x2 - x1 || 1);
}
return 0;
}
// ── Districts ──────────────────────────────────────────────────────────────
/** Paint one district (or a decoy copy) centered at cx, cy. */
export function paintDistrict(ctx: C, d: DistrictDef, cx: number, cy: number, seed: number, scale = 1): void {
const r = rng(seed + d.id.length * 101 + Math.round(cx * 13 + cy * 7));
const w = d.w * scale;
const h = d.h * scale;
const x0 = cx - w / 2;
const y0 = cy - h / 2;
// Lot.
ctx.fillStyle = d.kind === 'housing' ? '#7b8068' : d.kind === 'altstadt' ? '#6f655a' : '#8a877c';
ctx.fillRect(x0, y0, w, h);
ctx.strokeStyle = 'rgba(40,36,30,0.6)';
ctx.lineWidth = 0.05;
ctx.strokeRect(x0, y0, w, h);
switch (d.kind) {
case 'housing': {
// Khrushchyovka slabs in rows, green courtyards between them.
for (let y = y0 + 0.25; y < y0 + h - 0.2; y += 0.55) {
for (let x = x0 + 0.2; x < x0 + w - 0.8; x += 1.0) {
if (r() < 0.12) {
disc(ctx, x + 0.4, y + 0.1, 0.15, '#4d6a3b', false);
continue;
}
rect(ctx, x, y, 0.8, 0.16, r() < 0.5 ? PAL.concrete : '#b2ada0');
ctx.fillStyle = 'rgba(0,0,0,0.12)';
ctx.fillRect(x, y + 0.08, 0.8, 0.02);
}
}
break;
}
case 'altstadt': {
// Dense medieval roofs, a cathedral and a market square.
for (let i = 0; i < w * h * 28; i++) {
const x = x0 + r() * w;
const y = y0 + r() * h;
rect(ctx, x, y, 0.12 + r() * 0.14, 0.1 + r() * 0.12, r() < 0.7 ? PAL.roofRed : PAL.roofBrown, false);
}
ctx.fillStyle = '#b3a993';
ctx.fillRect(cx - 0.35, cy - 0.25, 0.7, 0.5);
rect(ctx, cx + 0.5, cy - 0.5, 0.9, 0.22, '#6c6a64');
rect(ctx, cx + 0.83, cy - 0.75, 0.22, 0.7, '#6c6a64');
disc(ctx, cx + 0.45, cy - 0.39, 0.12, '#4f7d6b');
break;
}
case 'steel': {
rect(ctx, x0 + 0.3, y0 + 0.3, w * 0.55, h * 0.35, PAL.roofDark);
rect(ctx, x0 + 0.3, y0 + h * 0.55, w * 0.4, h * 0.3, '#5a5550');
for (let i = 0; i < 4; i++) {
disc(ctx, x0 + w * 0.72 + (i % 2) * 0.6, y0 + 0.6 + Math.floor(i / 2) * 0.9, 0.28, '#3d3a37');
disc(ctx, x0 + w * 0.72 + (i % 2) * 0.6, y0 + 0.6 + Math.floor(i / 2) * 0.9, 0.12, '#a4492c', false);
}
for (let i = 0; i < 3; i++) disc(ctx, x0 + w * 0.2 + i * 0.5, y0 + h - 0.35, 0.3, '#2a2725');
for (let i = 0; i < 5; i++) disc(ctx, x0 + 0.5 + i * 0.25, y0 + 0.15, 0.07, '#8a5a3a');
break;
}
case 'machine': {
// Sawtooth-roof shop halls.
for (let k = 0; k < 2; k++) {
const bx = x0 + 0.25 + k * (w / 2);
rect(ctx, bx, y0 + 0.3, w / 2 - 0.45, h - 0.6, '#77787a');
ctx.strokeStyle = 'rgba(30,30,30,0.5)';
ctx.lineWidth = 0.05;
for (let y = y0 + 0.4; y < y0 + h - 0.35; y += 0.18) {
ctx.beginPath();
ctx.moveTo(bx, y);
ctx.lineTo(bx + w / 2 - 0.45, y);
ctx.stroke();
}
}
break;
}
case 'chem': {
for (let i = 0; i < 9; i++) disc(ctx, x0 + 0.5 + (i % 3) * 0.7, y0 + 0.5 + Math.floor(i / 3) * 0.7, 0.26, i % 2 ? '#d4cfc0' : '#bdb59f');
rect(ctx, x0 + w - 1.3, y0 + 0.3, 1, h - 0.6, '#6f6c66');
ctx.strokeStyle = '#d0b070';
ctx.lineWidth = 0.05;
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(x0 + 0.3, y0 + 0.3 + i * 0.6);
ctx.lineTo(x0 + w - 1.3, y0 + 0.4 + i * 0.6);
ctx.stroke();
}
disc(ctx, x0 + w - 0.8, y0 + h - 0.5, 0.18, '#3d3a37');
break;
}
case 'power': {
// Cooling towers, boiler hall, chimney, coal yard, switchyard.
disc(ctx, x0 + 0.65, y0 + 0.65, 0.5, '#cfcac0');
disc(ctx, x0 + 0.65, y0 + 0.65, 0.33, '#6d6a65', false);
disc(ctx, x0 + 0.65, y0 + 1.75, 0.5, '#cfcac0');
disc(ctx, x0 + 0.65, y0 + 1.75, 0.33, '#6d6a65', false);
rect(ctx, x0 + 1.35, y0 + 0.4, 1.0, 0.7, '#7e6a58');
disc(ctx, x0 + 1.55, y0 + 1.25, 0.1, '#3a3836');
ctx.fillStyle = '#1f1d1c';
ctx.beginPath();
ctx.ellipse(x0 + 1.9, y0 + 1.75, 0.45, 0.3, 0.2, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#9a9a92';
ctx.lineWidth = 0.03;
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(x0 + 1.45 + i * 0.22, y0 + 2.05);
ctx.lineTo(x0 + 1.45 + i * 0.22, y0 + 2.35);
ctx.stroke();
}
break;
}
case 'substation': {
ctx.fillStyle = '#9e9a8e';
ctx.fillRect(x0 + 0.1, y0 + 0.1, w - 0.2, h - 0.2);
for (let i = 0; i < 6; i++) rect(ctx, x0 + 0.2 + (i % 3) * 0.3, y0 + 0.25 + Math.floor(i / 3) * 0.35, 0.16, 0.16, '#4b4e50');
ctx.strokeStyle = '#d8d4c4';
ctx.lineWidth = 0.025;
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(x0, y0 + 0.2 + i * 0.3);
ctx.lineTo(x0 + w, y0 + 0.2 + i * 0.3);
ctx.stroke();
}
break;
}
case 'rail': {
// Fan of tracks with wagons and a roundhouse.
ctx.fillStyle = '#5d5548';
ctx.fillRect(x0, y0, w, h);
for (let t = 0; t < 14; t++) {
const y = y0 + 0.1 + (t / 14) * (h - 0.2);
ctx.strokeStyle = '#2a2723';
ctx.lineWidth = 0.05;
ctx.beginPath();
ctx.moveTo(x0 + 0.1, y);
ctx.lineTo(x0 + w - 0.1, y);
ctx.stroke();
let x = x0 + 0.2 + r() * 0.5;
while (x < x0 + w - 0.4) {
if (r() < 0.65) {
ctx.fillStyle = ['#6b3a2a', '#4f5a3c', '#3b4250', '#7a6a4a', '#2e2e2e'][Math.floor(r() * 5)];
ctx.fillRect(x, y - 0.045, 0.26, 0.09);
}
x += 0.3;
}
}
ctx.fillStyle = '#6c6258';
ctx.beginPath();
ctx.arc(x0 + w - 0.3, y0 + h / 2, 0.55, Math.PI * 0.5, Math.PI * 1.5);
ctx.fill();
break;
}
case 'port': {
for (let i = 0; i < 4; i++) {
rect(ctx, x0 + 0.3 + i * 1.15, y0 + 0.2, 0.75, 0.6, '#8b6e52');
rect(ctx, x0 + 0.5 + i * 1.15, y0 + h - 0.35, 0.18, 0.5, '#c4bfb3', false);
// Crane.
ctx.strokeStyle = '#d29a3a';
ctx.lineWidth = 0.06;
ctx.beginPath();
ctx.moveTo(x0 + 0.6 + i * 1.15, y0 + h - 0.6);
ctx.lineTo(x0 + 0.6 + i * 1.15, y0 + h + 0.2);
ctx.stroke();
}
// Ships along the quay.
for (let i = 0; i < 3; i++) {
const sx = x0 + 0.5 + i * 1.6;
const sy = y0 - 0.45;
ctx.fillStyle = PAL.shadow;
ctx.beginPath();
ctx.ellipse(sx + 0.65, sy + 0.05, 0.7, 0.16, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = i === 1 ? '#5a2e25' : '#2f3a40';
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + 1.1, sy - 0.12);
ctx.lineTo(sx + 1.35, sy);
ctx.lineTo(sx + 1.1, sy + 0.12);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#d6d0c0';
ctx.fillRect(sx + 0.15, sy - 0.06, 0.25, 0.12);
}
break;
}
case 'fuel': {
ctx.strokeStyle = 'rgba(90,80,60,0.9)';
ctx.lineWidth = 0.06;
for (let i = 0; i < 6; i++) {
const tx = x0 + 0.45 + (i % 3) * 0.75;
const ty = y0 + 0.5 + Math.floor(i / 3) * 0.8;
ctx.strokeRect(tx - 0.33, ty - 0.33, 0.66, 0.66);
disc(ctx, tx, ty, 0.27, '#d9d4c6');
disc(ctx, tx, ty, 0.08, '#8a8578', false);
}
break;
}
case 'warehouse': {
for (let i = 0; i < 5; i++) rect(ctx, x0 + 0.2 + i * 0.75, y0 + 0.25, 0.6, h - 0.5, i % 2 ? '#8c4e3a' : '#7a4434');
break;
}
case 'military': {
for (let i = 0; i < 8; i++) rect(ctx, x0 + 0.3 + (i % 4) * 0.85, y0 + 0.3 + Math.floor(i / 4) * 0.7, 0.65, 0.25, '#6d7158');
ctx.fillStyle = '#9b947c';
ctx.fillRect(x0 + 0.3, y0 + h - 1.0, w - 0.6, 0.6);
for (let i = 0; i < 10; i++) rect(ctx, x0 + 0.45 + i * 0.32, y0 + h - 0.85, 0.18, 0.1, '#3f4733', false);
break;
}
case 'airbase': {
ctx.fillStyle = '#5f6656';
ctx.fillRect(x0 - 0.3, y0 - 0.4, w + 0.6, h + 0.8);
ctx.fillStyle = '#8f8d86';
ctx.fillRect(x0, cy - 0.18, w, 0.36);
ctx.strokeStyle = '#f0ece0';
ctx.lineWidth = 0.04;
ctx.setLineDash([0.25, 0.2]);
ctx.beginPath();
ctx.moveTo(x0 + 0.2, cy);
ctx.lineTo(x0 + w - 0.2, cy);
ctx.stroke();
ctx.setLineDash([]);
for (let i = 0; i < 6; i++) {
disc(ctx, x0 + 0.4 + i * 0.7, cy + 0.65, 0.18, '#77786f');
disc(ctx, x0 + 0.4 + i * 0.7, cy - 0.65, 0.18, '#77786f');
}
break;
}
case 'govt': {
rect(ctx, x0 + 0.1, y0 + 0.1, w - 0.2, h - 0.2, '#b9b19b');
ctx.fillStyle = '#6b8a5a';
ctx.fillRect(x0 + 0.45, y0 + 0.4, w - 0.9, h - 0.8);
disc(ctx, cx, cy, 0.2, '#57794b', false);
// Radio mast.
ctx.strokeStyle = '#c03a2a';
ctx.lineWidth = 0.05;
ctx.beginPath();
ctx.moveTo(x0 + w - 0.2, y0 + 0.2);
ctx.lineTo(x0 + w + 0.4, y0 - 0.4);
ctx.stroke();
break;
}
case 'bridge': {
ctx.fillStyle = '#ada796';
ctx.fillRect(cx - 0.22, y0 - 0.6, 0.44, h + 1.2);
ctx.strokeStyle = '#4b4e50';
ctx.lineWidth = 0.05;
for (let y = y0 - 0.5; y < y0 + h + 0.6; y += 0.25) {
ctx.beginPath();
ctx.moveTo(cx - 0.22, y);
ctx.lineTo(cx + 0.22, y + 0.12);
ctx.stroke();
}
rect(ctx, cx - 0.35, y0 + h + 0.3, 0.7, 0.3, '#2b2a28');
break;
}
case 'hospital': {
rect(ctx, cx - 0.5, cy - 0.15, 1.0, 0.3, '#e3ded2');
rect(ctx, cx - 0.15, cy - 0.42, 0.3, 0.84, '#e3ded2');
ctx.fillStyle = '#d6261c';
ctx.fillRect(cx - 0.12, cy - 0.04, 0.24, 0.08);
ctx.fillRect(cx - 0.04, cy - 0.12, 0.08, 0.24);
break;
}
case 'dam': {
ctx.fillStyle = '#c9c4b6';
ctx.beginPath();
ctx.ellipse(cx + 0.6, cy, 0.5, h / 2 + 0.3, 0, Math.PI * 0.6, Math.PI * 1.4);
ctx.lineWidth = 0.35;
ctx.strokeStyle = '#c9c4b6';
ctx.stroke();
break;
}
case 'satellite': {
rect(ctx, x0 + 0.2, y0 + 0.2, w * 0.55, h * 0.5, '#6d6e6f');
for (let i = 0; i < 12; i++) rect(ctx, x0 + r() * w, y0 + h * 0.6 + r() * h * 0.35, 0.18, 0.14, PAL.roofRed, false);
disc(ctx, x0 + w * 0.8, y0 + 0.5, 0.12, '#2f2d2b');
break;
}
case 'interdiction': {
ctx.fillStyle = '#8a8476';
ctx.fillRect(x0, y0, w, h);
ctx.strokeStyle = '#2a2723';
ctx.lineWidth = 0.07;
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(x0, y0 + 0.25 + i * 0.25);
ctx.lineTo(x0 + w, y0 + 0.25 + i * 0.25);
ctx.stroke();
}
break;
}
}
}
/** Night window lights for a district (drawn on the light layer). */
export function districtLights(d: DistrictDef, seed: number): [number, number][] {
const r = rng(seed + d.id.length * 13);
const n = Math.round(d.w * d.h * (d.popDensity > 0.5 ? 14 : 5));
const pts: [number, number][] = [];
for (let i = 0; i < n; i++) pts.push([d.x + (r() - 0.5) * d.w, d.y + (r() - 0.5) * d.h]);
return pts;
}
// ── Composed layers ────────────────────────────────────────────────────────
export interface TerrainLayers {
world: HTMLCanvasElement; // whole theater, coarse
worldBox: { x0: number; y0: number; s: number };
city: HTMLCanvasElement; // city area, fine
cityBox: { x0: number; y0: number; s: number };
scars: HTMLCanvasElement; // bomb craters & scorch, same frame as city
}
let cached: TerrainLayers | null = null;
export function terrainLayers(seed = 1968): TerrainLayers {
if (cached) return cached;
// World.
const wb = { x0: -95, y0: -100, s: 5 };
const world = document.createElement('canvas');
world.width = Math.round(240 * wb.s);
world.height = Math.round(200 * wb.s);
const wctx = world.getContext('2d')!;
wctx.setTransform(wb.s, 0, 0, wb.s, -wb.x0 * wb.s, -wb.y0 * wb.s);
paintGround(wctx, wb.x0, wb.y0, wb.x0 + 240, wb.y0 + 200, seed, 0.8);
paintWater(wctx);
paintNetworks(wctx, seed);
paintSettlements(wctx, seed);
for (const d of DISTRICTS) paintDistrict(wctx, d, d.x, d.y, seed);
// City detail.
const cb = { x0: -48, y0: -36, s: 26 };
const cw = 80;
const ch = 70;
const city = document.createElement('canvas');
city.width = Math.round(cw * cb.s);
city.height = Math.round(ch * cb.s);
const cctx = city.getContext('2d')!;
cctx.setTransform(cb.s, 0, 0, cb.s, -cb.x0 * cb.s, -cb.y0 * cb.s);
paintGround(cctx, cb.x0, cb.y0, cb.x0 + cw, cb.y0 + ch, seed + 1, 2.2);
paintWater(cctx);
paintNetworks(cctx, seed);
paintSettlements(cctx, seed);
for (const d of DISTRICTS) paintDistrict(cctx, d, d.x, d.y, seed);
const scars = document.createElement('canvas');
scars.width = city.width;
scars.height = city.height;
cached = { world, worldBox: wb, city, cityBox: cb, scars };
return cached;
}
// ── On-demand detail tiles for the flown mission ──────────────────────────
export const TILE_KM = 8;
export const TILE_PX_PER_KM = 28;
const tileCache = new Map<string, HTMLCanvasElement>();
const tileOrder: string[] = [];
export function tileKey(i: number, j: number): string {
return `${i},${j}`;
}
export function cachedTile(i: number, j: number): HTMLCanvasElement | undefined {
return tileCache.get(tileKey(i, j));
}
/** Paint one 8×8 km tile at full detail (a few milliseconds). */
export function buildTile(i: number, j: number, seed = 1968): HTMLCanvasElement {
const key = tileKey(i, j);
const hit = tileCache.get(key);
if (hit) return hit;
const s = TILE_PX_PER_KM;
const c = document.createElement('canvas');
c.width = TILE_KM * s;
c.height = TILE_KM * s;
const ctx = c.getContext('2d')!;
const x0 = i * TILE_KM;
const y0 = j * TILE_KM;
ctx.setTransform(s, 0, 0, s, -x0 * s, -y0 * s);
ctx.beginPath();
ctx.rect(x0, y0, TILE_KM, TILE_KM);
ctx.clip();
paintGround(ctx, x0, y0, x0 + TILE_KM, y0 + TILE_KM, seed + 1, 2.2);
paintWater(ctx);
paintNetworks(ctx, seed);
paintSettlements(ctx, seed);
for (const d of DISTRICTS) {
if (Math.abs(d.x - (x0 + TILE_KM / 2)) < TILE_KM / 2 + d.w + 1 && Math.abs(d.y - (y0 + TILE_KM / 2)) < TILE_KM / 2 + d.h + 1) paintDistrict(ctx, d, d.x, d.y, seed);
}
tileCache.set(key, c);
tileOrder.push(key);
// Keep memory bounded (~80 tiles ≈ 40 MB worst case).
while (tileOrder.length > 80) tileCache.delete(tileOrder.shift()!);
return c;
}
/** Paint a crater (and scorch) onto the persistent scar layer. */
export function scar(layers: TerrainLayers, x: number, y: number, kg: number, burn: boolean): void {
const ctx = layers.scars.getContext('2d')!;
const b = layers.cityBox;
const px = (x - b.x0) * b.s;
const py = (y - b.y0) * b.s;
if (px < 0 || py < 0 || px > layers.scars.width || py > layers.scars.height) return;
const r = (0.06 + Math.sqrt(kg) * 0.004) * b.s;
const g = ctx.createRadialGradient(px, py, 0, px, py, r * (burn ? 3.2 : 2.2));
g.addColorStop(0, 'rgba(20,14,10,0.75)');
g.addColorStop(0.45, burn ? 'rgba(40,22,12,0.45)' : 'rgba(60,50,40,0.35)');
g.addColorStop(1, 'rgba(40,30,20,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(px, py, r * (burn ? 3.2 : 2.2), 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(8,6,4,0.85)';
ctx.beginPath();
ctx.arc(px, py, r * 0.7, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(170,155,120,0.45)';
ctx.lineWidth = Math.max(1, r * 0.25);
ctx.beginPath();
ctx.arc(px, py, r * 0.9, 0, Math.PI * 2);
ctx.stroke();
}
/** A small rendered picture of a district (for intel panels and briefings). */
export function districtPicture(d: DistrictDef, integrity: number, w = 220, h = 140, mode: 'color' | 'photo' = 'color'): string {
const c = document.createElement('canvas');
c.width = w * 2;
c.height = h * 2;
const ctx = c.getContext('2d')!;
const span = Math.max(d.w, d.h) * 1.9 + 1.2;
const s = (w * 2) / span;
ctx.setTransform(s, 0, 0, s, w - d.x * s, h - d.y * s);
const x0 = d.x - span / 2;
const y0 = d.y - (span * h) / w / 2;
paintGround(ctx, x0 - 1, y0 - 1, x0 + span + 1, y0 + span + 1, 77, 2.5);
paintWater(ctx);
paintNetworks(ctx, 1968);
paintDistrict(ctx, d, d.x, d.y, 1968);
// Damage overlay: craters and scorch proportional to destruction.
const dmg = 1 - integrity;
const r = rng(91);
for (let i = 0; i < dmg * 40; i++) {
const x = d.x + (r() - 0.5) * d.w * 1.1;
const y = d.y + (r() - 0.5) * d.h * 1.1;
const g = ctx.createRadialGradient(x, y, 0, x, y, 0.3);
g.addColorStop(0, 'rgba(15,10,8,0.85)');
g.addColorStop(1, 'rgba(15,10,8,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, 0.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
if (mode === 'photo') {
const img = ctx.getImageData(0, 0, c.width, c.height);
for (let i = 0; i < img.data.length; i += 4) {
const n = (Math.random() - 0.5) * 34;
const g = Math.max(0, Math.min(255, 0.3 * img.data[i] + 0.59 * img.data[i + 1] + 0.11 * img.data[i + 2] + 20 + n));
img.data[i] = g;
img.data[i + 1] = g;
img.data[i + 2] = g * 0.94;
}
ctx.putImageData(img, 0, 0);
}
return c.toDataURL('image/jpeg', 0.82);
}
