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/ui/stratmap.ts
// DEFCON-style theater map: glowing districts (glow = power), animated supply
// flows that reroute, stale SAM rings, raid heat, smoke columns.
import { CONFIG } from '../game/config';
import { bridgeFactor, routeStatus } from '../game/city';
import {
  BARRIER_RADARS, BARRIER_X, DISTRICTS, PONTOON_POS, RIVER, ROUTES, SUPPLY_ROUTES, type RouteId,
} from '../game/map';
import { DISTRICT_BY_ID, type DistrictKind } from '../game/map';
import { decoyRisk } from '../game/city';
import { lightLevel, type GameState } from '../game/state';
import { terrainLayers } from '../art/terrain';
import { hideTip, showTipAt } from './tooltip';

const TAU = Math.PI * 2;

export interface MapView {
  cx: number;
  cy: number;
  span: number; // km visible across the short side
}

export class StratMap {
  readonly canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  view: MapView = { cx: 0, cy: 0, span: 62 };
  selected: string | null = null;
  route: RouteId | null = null;
  aim: { x: number; y: number } | null = null;
  private t = 0;
  private raf = 0;
  private dpr = 1;
  private W = 0;
  private H = 0;
  private drag: { id: number; x: number; y: number; cx: number; cy: number; moved: boolean } | null = null;
  onSelect: (id: string | null) => void = () => undefined;
  private hover: string | null = null;

  constructor(private s: GameState) {
    this.canvas = document.createElement('canvas');
    this.canvas.className = 'strat-canvas';
    this.canvas.setAttribute('role', 'img');
    this.canvas.setAttribute('aria-label', 'Theater map of Hafenstadt');
    this.ctx = this.canvas.getContext('2d')!;
    this.canvas.addEventListener('pointerdown', (e) => {
      this.drag = { id: e.pointerId, x: e.clientX, y: e.clientY, cx: this.view.cx, cy: this.view.cy, moved: false };
      this.canvas.setPointerCapture(e.pointerId);
    });
    this.canvas.addEventListener('pointermove', (e) => {
      if (!this.drag && e.pointerType !== 'touch') {
        const id = this.hit(e.clientX, e.clientY);
        if (id) showTipAt(e.clientX, e.clientY, this.describe(id));
        else hideTip();
        this.hover = id;
      }
      if (!this.drag || this.drag.id !== e.pointerId) return;
      const dx = e.clientX - this.drag.x;
      const dy = e.clientY - this.drag.y;
      if (Math.hypot(dx, dy) > 6) this.drag.moved = true;
      if (this.drag.moved) {
        const z = this.zoom();
        this.view.cx = this.drag.cx - dx / z;
        this.view.cy = this.drag.cy - dy / z;
      }
    });
    this.canvas.addEventListener('pointerup', (e) => {
      if (this.drag && !this.drag.moved) this.pick(e.clientX, e.clientY);
      this.drag = null;
    });
    this.canvas.addEventListener('pointercancel', () => (this.drag = null));
    this.canvas.addEventListener('pointerleave', () => { hideTip(); this.hover = null; });
    this.canvas.addEventListener('wheel', (e) => {
      e.preventDefault();
      this.view.span = Math.max(20, Math.min(240, this.view.span * (e.deltaY > 0 ? 1.12 : 0.89)));
    }, { passive: false });
  }

  setState(s: GameState): void {
    this.s = s;
  }

  start(): void {
    const loop = () => {
      this.raf = requestAnimationFrame(loop);
      this.t += 1 / 60;
      this.draw();
    };
    this.raf = requestAnimationFrame(loop);
  }

  stop(): void {
    cancelAnimationFrame(this.raf);
  }

  setSpan(span: number, cx = 0, cy = 0): void {
    this.view = { cx, cy, span };
  }

  private zoom(): number {
    return Math.min(this.W, this.H) / this.view.span;
  }

  private pick(clientX: number, clientY: number): void {
    const best = this.hit(clientX, clientY);
    this.selected = best;
    this.onSelect(best);
  }

  /** What is under the pointer: district id, `sam:`, `farm:` or `picket:`. */
  private hit(clientX: number, clientY: number): string | null {
    const r = this.canvas.getBoundingClientRect();
    const z = this.zoom();
    const x = this.view.cx + (clientX - r.left - this.W / 2) / z;
    const y = this.view.cy + (clientY - r.top - this.H / 2) / z;
    let best: string | null = null;
    let bd = 10 / z + 0.6;
    for (const d of DISTRICTS) {
      const dd = Math.max(Math.abs(x - d.x) - d.w / 2, Math.abs(y - d.y) - d.h / 2, 0);
      if (dd < bd) { bd = dd; best = d.id; }
    }
    for (const site of this.s.sams) {
      const dd = Math.hypot(site.knownX - x, site.knownY - y) - 0.6;
      if (dd < bd && site.launchers > 0.05) { bd = dd; best = `sam:${site.id}`; }
    }
    for (const f of this.s.city.fuelFarms) {
      if (!f.known || f.destroyed) continue;
      const dd = Math.hypot(f.x - x, f.y - y) - 0.6;
      if (dd < bd) { bd = dd; best = `farm:${f.id}`; }
    }
    BARRIER_RADARS.forEach(([px, py], i) => {
      const dd = Math.hypot(px - x, py - y) - 0.8;
      if (dd < bd) { bd = dd; best = `picket:${i}`; }
    });
    return best;
  }

  /** Hover description for anything on the map. */
  describe(id: string): string {
    const s = this.s;
    const pct = (v: number) => `${Math.round(v * 100)}%`;
    if (id.startsWith('sam:')) {
      const site = s.sams.find((q) => q.id === id.slice(4));
      if (!site) return '';
      const age = Math.round((s.hour - site.knownHour) / 24);
      return `<b>SAM battery ${site.id} — ${site.kind === 'nike' ? 'Nike Hercules' : 'Bloodhound'}</b>Radar-guided missiles, range ${CONFIG.sam[site.kind].range} km (the ring). ${site.kind === 'nike' ? 'Deadly high, weak below 1,200 m.' : 'Reaches down to 350 m.'}<br>${site.revealed ? '<em>Revealed as a DECOY.</em>' : site.disabledUntil > s.hour ? 'Knocked out for now.' : `Position last photographed ${age} day${age === 1 ? '' : 's'} ago${age > CONFIG.adaptation.intelFreshDays ? ' — stale: it may have moved, and a dummy may stand here' : ''}.`}<br><i>Click to select · plan a raid on it.</i>`;
    }
    if (id.startsWith('farm:')) return `<b>Fuel drum farm</b>Hidden fuel for SAM reload trucks and interceptors. Small and dispersed — but every one destroyed starves the defense.`;
    if (id.startsWith('picket:')) return `<b>Early-warning picket radar</b>Part of the barrier. It spots your raid ~55 km out (much less if you fly low) and scrambles the fighters. Su-7s with rockets can kill it at the start of a raid — the Desert Storm opening move.`;
    const d = DISTRICT_BY_ID[id];
    if (!d) return '';
    const st = s.districts[id];
    const age = Math.round(((s.hour - st.intelHour) / 24) * 10) / 10;
    const risk = decoyRisk(s, id);
    return `<b>${d.name}</b>${KIND_HELP[d.kind] ?? ''}<br>Condition: ${pct(st.knownIntegrity)} (photo ${age} d old) · ${st.powered ? 'power ON' : '<em>no power</em>'}${st.fire > 0.1 ? ' · <em>burning</em>' : ''}${risk > 0.15 ? ` · decoy risk ${pct(risk)}` : ''}${d.restricted ? '<br><em>Restricted target — huge political cost.</em>' : ''}<br><i>Click to select · then Plan raid.</i>`;
  }

  private resize(): void {
    const r = this.canvas.getBoundingClientRect();
    this.dpr = Math.min(2, window.devicePixelRatio || 1);
    if (Math.abs(r.width - this.W) > 0.5 || Math.abs(r.height - this.H) > 0.5) {
      this.W = Math.max(1, r.width);
      this.H = Math.max(1, r.height);
      this.canvas.width = Math.round(this.W * this.dpr);
      this.canvas.height = Math.round(this.H * this.dpr);
    }
  }

  draw(): void {
    this.resize();
    const ctx = this.ctx;
    const s = this.s;
    const W = this.W;
    const H = this.H;
    const z = this.zoom();
    const X = (x: number) => W / 2 + (x - this.view.cx) * z;
    const Y = (y: number) => H / 2 + (y - this.view.cy) * z;
    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
    ctx.fillStyle = '#03080a';
    ctx.fillRect(0, 0, W, H);
    const night = 1 - lightLevel(s.hour);
    // Painted terrain beneath the plot, dimmed like a lit map table.
    const L = terrainLayers();
    ctx.save();
    ctx.setTransform(this.dpr * z, 0, 0, this.dpr * z, this.dpr * (W / 2 - this.view.cx * z), this.dpr * (H / 2 - this.view.cy * z));
    ctx.globalAlpha = 0.55;
    ctx.drawImage(L.world, L.worldBox.x0, L.worldBox.y0, L.world.width / L.worldBox.s, L.world.height / L.worldBox.s);
    if (z > 6) ctx.drawImage(L.city, L.cityBox.x0, L.cityBox.y0, L.city.width / L.cityBox.s, L.city.height / L.cityBox.s);
    ctx.restore();
    ctx.fillStyle = `rgba(2,10,16,${0.5 + night * 0.2})`;
    ctx.fillRect(0, 0, W, H);

    // Range rings and grid.
    ctx.strokeStyle = 'rgba(70,170,180,0.08)';
    ctx.lineWidth = 1;
    for (let r = 10; r <= 120; r += 10) {
      ctx.beginPath();
      ctx.arc(X(0), Y(0), r * z, 0, TAU);
      ctx.stroke();
    }
    ctx.beginPath();
    ctx.moveTo(X(-200), Y(0)); ctx.lineTo(X(200), Y(0));
    ctx.moveTo(X(0), Y(-200)); ctx.lineTo(X(0), Y(200));
    ctx.stroke();

    // Sea to the west.
    ctx.fillStyle = 'rgba(30,70,100,0.12)';
    ctx.fillRect(X(-300), Y(-300), Math.max(0, X(-80) - X(-300)), 600 * z);
    // Barrier.
    ctx.setLineDash([5, 7]);
    ctx.strokeStyle = 'rgba(255,80,60,0.35)';
    ctx.beginPath();
    ctx.moveTo(X(BARRIER_X), Y(-95));
    ctx.lineTo(X(BARRIER_X), Y(95));
    ctx.stroke();
    ctx.setLineDash([]);
    ctx.strokeStyle = 'rgba(255,100,80,0.9)';
    ctx.lineWidth = 1.5;
    ctx.font = '600 10px ui-monospace, monospace';
    for (const [x, y] of BARRIER_RADARS) {
      const px = X(x);
      const py = Y(y);
      ctx.beginPath();
      ctx.moveTo(px - 5, py + 6); ctx.lineTo(px, py - 7); ctx.lineTo(px + 5, py + 6);
      ctx.moveTo(px - 7, py - 7); ctx.lineTo(px + 7, py - 7);
      ctx.stroke();
      if (z > 2.5) {
        ctx.fillStyle = 'rgba(255,150,130,0.85)';
        ctx.fillText('EARLY WARNING', px + 9, py - 2);
      }
    }
    // River.
    ctx.strokeStyle = 'rgba(70,150,200,0.55)';
    ctx.lineWidth = Math.max(1.5, 0.8 * z);
    ctx.beginPath();
    RIVER.forEach(([x, y], i) => (i ? ctx.lineTo(X(x), Y(y)) : ctx.moveTo(X(x), Y(y))));
    ctx.stroke();

    // Planned route preview.
    if (this.route) {
      const r = ROUTES[this.route];
      const aim = this.aim ?? { x: 0, y: 0 };
      ctx.strokeStyle = 'rgba(95,211,255,0.7)';
      ctx.lineWidth = 1.5;
      ctx.setLineDash([8, 5]);
      ctx.beginPath();
      [...r.ingress, [aim.x, aim.y] as [number, number], ...r.egress].forEach(([x, y], i) => (i ? ctx.lineTo(X(x), Y(y)) : ctx.moveTo(X(x), Y(y))));
      ctx.stroke();
      ctx.setLineDash([]);
    }

    // Supply routes with animated flows; they stop where cut and reroute over the pontoon.
    for (const r of SUPPLY_ROUTES) {
      const st = routeStatus(s, r.id);
      const pts = r.viaBridge && bridgeFactor(s) > 0 && s.districts.bridge.integrity < 0.35
        ? r.points.map(([x, y]) => (Math.abs(x - 3.3) < 0.1 && Math.abs(y - 0.6) < 0.1 ? PONTOON_POS : [x, y]) as [number, number])
        : r.points;
      ctx.strokeStyle = r.mode === 'rail' ? 'rgba(180,200,210,0.35)' : r.mode === 'river' ? 'rgba(90,170,220,0.3)' : 'rgba(200,190,140,0.3)';
      ctx.lineWidth = 1;
      ctx.setLineDash(r.mode === 'rail' ? [5, 3] : []);
      ctx.beginPath();
      pts.forEach(([x, y], i) => (i ? ctx.lineTo(X(x), Y(y)) : ctx.moveTo(X(x), Y(y))));
      ctx.stroke();
      ctx.setLineDash([]);
      // Flow dots.
      const lens = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1]));
      const total = lens.reduce((a, b) => a + b, 0);
      const n = Math.round(4 + st * 14);
      ctx.fillStyle = st > 0.05 ? (r.mode === 'river' ? '#6fc6ff' : r.mode === 'rail' ? '#e8f2f5' : '#f0dc96') : 'rgba(255,60,50,0.9)';
      for (let i = 0; i < n; i++) {
        if (st <= 0.05 && i > 0) break;
        let d = ((this.t * (2 + st * 6) + (i * total) / n) % total);
        if (st <= 0.05) d = total * 0.4;
        let k = 0;
        while (k < lens.length - 1 && d > lens[k]) { d -= lens[k]; k++; }
        const f = d / (lens[k] || 1);
        const x = pts[k][0] + (pts[k + 1][0] - pts[k][0]) * f;
        const y = pts[k][1] + (pts[k + 1][1] - pts[k][1]) * f;
        ctx.fillRect(X(x) - 1.2, Y(y) - 1.2, 2.4, 2.4);
      }
    }
    if (s.city.pontoon > 0.05 && s.districts.bridge.integrity < 0.35) {
      ctx.strokeStyle = `rgba(255,220,120,${0.4 + 0.5 * s.city.pontoon})`;
      ctx.setLineDash([2, 2]);
      ctx.beginPath();
      ctx.moveTo(X(PONTOON_POS[0]), Y(PONTOON_POS[1] - 1.2));
      ctx.lineTo(X(PONTOON_POS[0]), Y(PONTOON_POS[1] + 1.2));
      ctx.stroke();
      ctx.setLineDash([]);
    }

    // Raid heat markings.
    for (const h of s.raidHeat) {
      const age = (s.hour - h.hour) / 24;
      const a = Math.max(0, 0.35 - age * 0.02);
      if (a <= 0) continue;
      ctx.fillStyle = `rgba(255,90,40,${a})`;
      ctx.fillRect(X(h.x) - 1, Y(h.y) - 1, 2, 2);
    }

    // Districts: glow = power.
    ctx.globalCompositeOperation = 'lighter';
    for (const d of DISTRICTS) {
      const st = s.districts[d.id];
      const lit = st.powered && st.integrity > 0.2;
      // Decoys appear once photographed; they look exactly like the real thing until revealed.
      const shapes: { x: number; y: number; dec?: { revealed: boolean } }[] = [{ x: d.x, y: d.y }, ...st.decoys.filter((q) => !q.destroyed && q.seen).map((q) => ({ x: q.x, y: q.y, dec: q }))];
      for (const sh of shapes) {
        const isDecoy = !!sh.dec;
        const integ = isDecoy ? 1 : st.knownIntegrity;
        const ww = (isDecoy ? d.w * 0.8 : d.w) * z;
        const hh = (isDecoy ? d.h * 0.8 : d.h) * z;
        const x0 = X(sh.x) - ww / 2;
        const y0 = Y(sh.y) - hh / 2;
        const civ = d.kind === 'housing' || d.kind === 'altstadt' || d.kind === 'hospital';
        const base = civ ? [130, 170, 190] : d.kind === 'dam' ? [130, 160, 200] : [255, 180, 90];
        const glow = (isDecoy ? 1 : lit ? 1 : 0.15) * (0.3 + 0.7 * integ) * (0.6 + night * 0.5);
        ctx.fillStyle = `rgba(${base[0]},${base[1]},${base[2]},${0.12 * glow})`;
        ctx.fillRect(x0 - 3, y0 - 3, ww + 6, hh + 6);
        ctx.fillStyle = `rgba(${base[0]},${base[1]},${base[2]},${0.22 * glow})`;
        ctx.fillRect(x0, y0, ww, hh);
        ctx.strokeStyle = `rgba(${base[0]},${base[1]},${base[2]},${0.25 + 0.6 * glow})`;
        ctx.lineWidth = this.selected === d.id && !isDecoy ? 2 : 1;
        ctx.strokeRect(x0, y0, ww, hh);
        if (sh.dec?.revealed) {
          ctx.strokeStyle = 'rgba(255,220,80,0.8)';
          ctx.beginPath();
          ctx.moveTo(x0, y0); ctx.lineTo(x0 + ww, y0 + hh); ctx.moveTo(x0 + ww, y0); ctx.lineTo(x0, y0 + hh);
          ctx.stroke();
        }
      }
    }
    ctx.globalCompositeOperation = 'source-over';
    // Icons: what each district is, at a glance.
    for (const d of DISTRICTS) {
      const st = s.districts[d.id];
      const size = Math.max(9, Math.min(20, Math.min(d.w, d.h) * z * 0.7));
      const lit = st.powered && st.integrity > 0.2;
      const col = d.restricted ? '#9fd0ff' : d.kind === 'housing' || d.kind === 'altstadt' ? '#cfe6ee' : lit ? '#ffd9a0' : '#9a8a78';
      drawIcon(ctx, d.kind, X(d.x), Y(d.y), size, col);
    }
    // Damage hatch + labels (labels skip spots already taken, important ones first).
    ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace';
    ctx.textBaseline = 'middle';
    const placed: [number, number, number, number][] = [];
    const order = [...DISTRICTS].sort((a, b) => Number(b.id === this.selected || b.id === this.hover) - Number(a.id === this.selected || a.id === this.hover) || b.militaryValue - a.militaryValue);
    for (const d of order) {
      const st = s.districts[d.id];
      const x0 = X(d.x - d.w / 2);
      const y0 = Y(d.y - d.h / 2);
      if (st.knownIntegrity < 0.9) {
        ctx.strokeStyle = `rgba(0,0,0,${0.8 * (1 - st.knownIntegrity)})`;
        ctx.beginPath();
        const n = Math.round((1 - st.knownIntegrity) * 8);
        for (let i = 0; i < n; i++) {
          const t = (i + 0.5) / n;
          ctx.moveTo(x0 + d.w * z * t, y0);
          ctx.lineTo(x0, y0 + d.h * z * t);
        }
        ctx.stroke();
      }
      if (z > 6 || d.id === this.selected || d.id === this.hover) {
        const label = z > 16 || d.id === this.hover ? d.name : SHORT[d.id] ?? d.name.split(/[ (]/)[0];
        ctx.font = '600 10px ui-monospace, monospace';
        const w = ctx.measureText(label).width;
        const lx = X(d.x) - w / 2;
        const ly = Y(d.y + d.h / 2) + 8;
        const box: [number, number, number, number] = [lx - 2, ly - 6, w + 4, 12];
        const important = d.id === this.selected || d.id === this.hover;
        const taken = !important && placed.some((b) => box[0] < b[0] + b[2] && box[0] + box[2] > b[0] && box[1] < b[1] + b[3] && box[1] + box[3] > b[1]);
        if (!taken) {
          placed.push(box);
          ctx.fillStyle = 'rgba(0,0,0,0.55)';
          ctx.fillRect(lx - 2, ly - 6, w + 4, 12);
          ctx.fillStyle = important ? '#ffe9b0' : 'rgba(215,232,236,0.85)';
          ctx.fillText(label, lx, ly);
        }
      }
      if (st.ldbHours > 0) {
        ctx.fillStyle = '#ffda6a';
        ctx.fillText('⏱', X(d.x) + d.w * z / 2 + 2, Y(d.y));
      }
    }

    // Smoke columns from fires, drifting.
    for (const d of DISTRICTS) {
      const f = s.districts[d.id].fire;
      if (f < 0.05) continue;
      for (let i = 0; i < 6; i++) {
        const k = ((this.t * 0.15 + i / 6) % 1);
        const x = d.x + k * 6 * f + Math.sin(i * 7 + this.t) * 0.3;
        const y = d.y - k * 3;
        ctx.fillStyle = `rgba(${night > 0.5 ? 90 : 60},${night > 0.5 ? 50 : 55},${night > 0.5 ? 30 : 55},${(1 - k) * 0.35 * Math.min(1, f * 2)})`;
        ctx.beginPath();
        ctx.arc(X(x), Y(y), (0.4 + k * 1.6 * f) * z, 0, TAU);
        ctx.fill();
      }
      ctx.globalCompositeOperation = 'lighter';
      ctx.fillStyle = `rgba(255,120,40,${0.25 * f + 0.1 * Math.random()})`;
      ctx.beginPath();
      ctx.arc(X(d.x), Y(d.y), Math.max(2, d.w * z * 0.4 * f), 0, TAU);
      ctx.fill();
      ctx.globalCompositeOperation = 'source-over';
    }

    // SAM sites at last known positions (dashed = stale intel).
    const fresh = CONFIG.adaptation.intelFreshDays * 24;
    for (const site of s.sams) {
      if (site.launchers <= 0.05 && !site.isDecoy) continue;
      if (site.knownHour < -1000) continue; // never photographed
      const stale = s.hour - site.knownHour > fresh;
      const x = X(site.knownX);
      const y = Y(site.knownY);
      const disabled = site.disabledUntil > s.hour;
      ctx.strokeStyle = site.revealed ? 'rgba(255,220,80,0.7)' : disabled ? 'rgba(150,150,150,0.6)' : 'rgba(255,70,60,0.85)';
      ctx.lineWidth = 1.3;
      ctx.beginPath();
      for (let i = 0; i < 6; i++) ctx.lineTo(x + Math.cos((i / 6) * TAU) * 5, y + Math.sin((i / 6) * TAU) * 5);
      ctx.closePath();
      ctx.stroke();
      if (!site.revealed && !disabled) {
        ctx.setLineDash(stale ? [3, 6] : []);
        ctx.strokeStyle = stale ? 'rgba(255,70,60,0.12)' : 'rgba(255,70,60,0.2)';
        ctx.beginPath();
        ctx.arc(x, y, CONFIG.sam[site.kind].range * z, 0, TAU);
        ctx.stroke();
        ctx.setLineDash([]);
      }
      if (this.selected === `sam:${site.id}`) {
        ctx.strokeStyle = '#ffe9b0';
        ctx.strokeRect(x - 8, y - 8, 16, 16);
      }
      if (z > 6) {
        ctx.fillStyle = 'rgba(255,140,120,0.8)';
        ctx.fillText(site.revealed ? `${site.id} DECOY` : site.id, x + 7, y - 6);
      }
    }
    // Fuel drum farms.
    for (const f of s.city.fuelFarms) {
      if (!f.known) continue;
      ctx.strokeStyle = f.destroyed ? 'rgba(120,120,120,0.6)' : 'rgba(255,200,90,0.9)';
      ctx.strokeRect(X(f.x) - 3, Y(f.y) - 3, 6, 6);
      if (this.selected === `farm:${f.id}`) ctx.strokeRect(X(f.x) - 7, Y(f.y) - 7, 14, 14);
    }
    // Target aim marker.
    if (this.aim) {
      ctx.strokeStyle = '#ffd36a';
      ctx.lineWidth = 1.5;
      const x = X(this.aim.x);
      const y = Y(this.aim.y);
      ctx.beginPath();
      ctx.moveTo(x, y - 9); ctx.lineTo(x + 9, y); ctx.lineTo(x, y + 9); ctx.lineTo(x - 9, y); ctx.closePath();
      ctx.stroke();
    }
    // Scanline sweep for flavor.
    const sweep = (this.t * 0.4) % TAU;
    ctx.strokeStyle = 'rgba(95,211,255,0.06)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(X(0), Y(0));
    ctx.lineTo(X(0) + Math.cos(sweep) * Math.max(W, H), Y(0) + Math.sin(sweep) * Math.max(W, H));
    ctx.stroke();
  }
}

const SHORT: Record<string, string> = {
  yards: 'Rail yards', steel: 'Steel works', machine: 'Machine works', chem: 'Chemicals', port: 'River port', fueldepot: 'Fuel depot',
  power: 'Power plant', subN: 'Substation N', subE: 'Substation E', subW: 'Substation W', subS: 'Substation S', warehouse: 'Warehouses',
  altstadt: 'Old town', housingN: 'Housing N', housingW: 'Housing W', housingS: 'Housing S', military: 'SAM garrison', airbase: 'Fighter base',
  govt: 'Government', bridge: 'Bridge', hospital: 'Hospital', dam: 'Dam', satN: 'Nordheim works', satW: 'Westwald works', satS: 'Südhang works',
  railE: 'East rail line', railN: 'North rail line', railS: 'South rail line', lock: 'Barge lock', road: 'Convoy road',
};

const KIND_HELP: Partial<Record<DistrictKind, string>> = {
  power: 'Feeds every substation. Knock it out and factories slow, SAM radars run on batteries, searchlights die and fire pumps stop.',
  substation: 'Distributes power to its part of the city. Cheap to kill, quick to repair — the grid can import power through the others.',
  rail: 'Coal, ore, SAM reloads and fighter parts all pass here. Repaired fast; strike again before they finish.',
  steel: 'Steel for weapons and for repairs. Needs coal by rail.',
  machine: 'Weapons and engines. Needs steel and power.',
  chem: 'Explosives and fuel products. Burns well.',
  port: 'River barges bring supplies from the sea.',
  fuel: 'Fuel for SAM trucks and interceptors. Burns spectacularly — then the city hides fuel in the hills.',
  warehouse: 'Storage. Timber roofs, flammable.',
  altstadt: 'Timber old town, densely populated. Incendiaries here can start a firestorm — and horrify the world.',
  housing: 'Apartment blocks where 400,000 workers live — and where 40% of output is made in small workshops. Bombing them kills civilians.',
  military: 'SAM garrison and missile reload depot.',
  airbase: 'Interceptor base. Cratering it means fewer fighters scramble.',
  govt: 'Government and communications. Hard, and politically sensitive.',
  bridge: 'The only river crossing for the south rail line and the road. Drop it and the city floats a pontoon ferry in ~3 days.',
  hospital: 'Protected. Hitting it is a political catastrophe.',
  dam: 'Protected. Breaching it floods the suburbs.',
  satellite: 'Dispersed factory town in the hills. Grows as production moves out of the bombed city.',
  interdiction: 'A choke point on a supply line. Cutting it starves the city and its air defense.',
};

/** Simple vector pictograms for the plotting table. */
export function drawIcon(ctx: CanvasRenderingContext2D, kind: DistrictKind, x: number, y: number, s: number, color: string): void {
  ctx.save();
  ctx.translate(x, y);
  ctx.strokeStyle = color;
  ctx.fillStyle = color;
  ctx.lineWidth = Math.max(1.2, s * 0.09);
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  const h = s / 2;
  const line = (pts: number[]) => {
    ctx.beginPath();
    for (let i = 0; i < pts.length; i += 2) (i ? ctx.lineTo(pts[i] * h, pts[i + 1] * h) : ctx.moveTo(pts[i] * h, pts[i + 1] * h));
    ctx.stroke();
  };
  const circ = (cx: number, cy: number, r: number, fill = false) => {
    ctx.beginPath();
    ctx.arc(cx * h, cy * h, r * h, 0, Math.PI * 2);
    if (fill) ctx.fill();
    else ctx.stroke();
  };
  switch (kind) {
    case 'power': circ(-0.45, 0.2, 0.45); circ(0.45, 0.2, 0.45); line([-0.2, -0.9, -0.2, -0.3]); break;
    case 'substation': line([0.15, -0.9, -0.35, 0.05, 0.15, 0.05, -0.15, 0.9]); break;
    case 'rail': line([-0.9, -0.35, 0.9, -0.35]); line([-0.9, 0.35, 0.9, 0.35]); for (let i = -0.7; i <= 0.7; i += 0.35) line([i, -0.55, i, 0.55]); break;
    case 'steel': line([-0.8, 0.8, -0.8, -0.2, 0.2, -0.2, 0.2, 0.8, -0.8, 0.8]); line([0.5, 0.8, 0.5, -0.9]); circ(0.5, -0.9, 0.12, true); break;
    case 'machine': circ(0, 0, 0.45); for (let a = 0; a < 6; a++) { const c = Math.cos((a * Math.PI) / 3); const sn = Math.sin((a * Math.PI) / 3); line([c * 0.55, sn * 0.55, c * 0.85, sn * 0.85]); } break;
    case 'chem': line([-0.3, -0.85, -0.3, -0.2, -0.75, 0.75, 0.75, 0.75, 0.3, -0.2, 0.3, -0.85]); line([-0.45, 0.3, 0.45, 0.3]); break;
    case 'port': line([0, -0.85, 0, 0.75]); line([-0.6, 0.35, -0.5, 0.75, 0.5, 0.75, 0.6, 0.35]); line([-0.35, -0.45, 0.35, -0.45]); break;
    case 'fuel': line([-0.55, -0.75, 0.55, -0.75, 0.55, 0.75, -0.55, 0.75, -0.55, -0.75]); line([-0.55, -0.25, 0.55, -0.25]); line([-0.55, 0.25, 0.55, 0.25]); break;
    case 'warehouse': line([-0.85, 0.75, -0.85, -0.2, 0, -0.75, 0.85, -0.2, 0.85, 0.75, -0.85, 0.75]); break;
    case 'altstadt': line([-0.7, 0.8, -0.7, -0.1, 0, -0.85, 0.7, -0.1, 0.7, 0.8]); line([0, -0.85, 0, -1]); line([-0.15, 0.8, -0.15, 0.3, 0.15, 0.3, 0.15, 0.8]); break;
    case 'housing': for (let i = -1; i <= 1; i++) line([i * 0.55 - 0.2, 0.8, i * 0.55 - 0.2, -0.6, i * 0.55 + 0.2, -0.6, i * 0.55 + 0.2, 0.8]); break;
    case 'military': {
      ctx.beginPath();
      for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5; const r = i % 2 ? 0.38 : 0.9; ctx.lineTo(Math.cos(a) * r * h, Math.sin(a) * r * h); }
      ctx.closePath();
      ctx.stroke();
      break;
    }
    case 'airbase': line([-0.9, 0, 0.9, 0]); line([0.35, -0.75, 0.15, 0, 0.35, 0.75]); line([-0.6, -0.25, -0.7, 0, -0.6, 0.25]); break;
    case 'govt': line([-0.8, 0.8, 0.8, 0.8]); line([-0.6, 0.8, -0.6, -0.1]); line([0, 0.8, 0, -0.1]); line([0.6, 0.8, 0.6, -0.1]); line([-0.85, -0.1, 0, -0.75, 0.85, -0.1, -0.85, -0.1]); break;
    case 'bridge': ctx.beginPath(); ctx.arc(0, h * 0.7, h * 0.85, Math.PI, 0); ctx.stroke(); line([-0.95, -0.2, 0.95, -0.2]); break;
    case 'hospital': ctx.fillRect(-h * 0.2, -h * 0.75, h * 0.4, h * 1.5); ctx.fillRect(-h * 0.75, -h * 0.2, h * 1.5, h * 0.4); break;
    case 'dam': line([-0.9, -0.6, -0.5, 0.7, 0.5, 0.7, 0.9, -0.6]); line([-0.4, -0.2, 0.4, -0.2]); break;
    case 'satellite': line([-0.8, 0.75, -0.8, -0.1, -0.3, 0.2, -0.3, -0.1, 0.2, 0.2, 0.2, -0.1, 0.7, 0.2, 0.7, 0.75, -0.8, 0.75]); line([0.45, -0.1, 0.45, -0.8]); break;
    case 'interdiction': line([-0.9, -0.3, 0.9, -0.3]); line([-0.9, 0.3, 0.9, 0.3]); line([-0.45, -0.75, 0.45, 0.75]); line([0.45, -0.75, -0.45, 0.75]); break;
  }
  ctx.restore();
}