Back to City Breaker
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.

src/art/terrain.ts
// 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);
}