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.
// 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();
}
