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/mission.ts
// "Into the warzone": the flown raid over a painted city, with a HUD that tells
// you what you are looking at, and hell over the target.
import { clouds, glow, planeBankShade, planeCard, planeShadow, planeSprite, puff, samSite } from '../art/sprites';
import { TILE_KM, buildTile, cachedTile, districtLights, districtPicture, paintDistrict, rng, scar, terrainLayers, type TerrainLayers } from '../art/terrain';
import { audio } from '../game/audio';
import { CONFIG } from '../game/config';
import { FxPool, ScreenFx } from '../game/fx';
import { BARRIER_X, DISTRICTS, DISTRICT_BY_ID, ROUTES } from '../game/map';
import { prefs } from '../game/prefs';
import { autoResolve, ccip, createWorld, finishWorld, step, type Plane, type World } from '../game/sim';
import type { MissionPlan, MissionResult } from '../game/campaign';
import type { GameState } from '../game/state';
import { fmtTime } from '../game/state';
import { GLYPH_SIZE } from './glyphs';
import { bayDoors, bomb, drop, flame, formation, fuelCan, gauge, keycap, releaseMode } from './hudart';
import { esc } from './dom';

const TAU = Math.PI * 2;
const clamp = (v: number, lo = 0, hi = 1) => Math.max(lo, Math.min(hi, v));
const wrap = (a: number) => {
  while (a > Math.PI) a -= TAU;
  while (a < -Math.PI) a += TAU;
  return a;
};

interface RadioLine { from: string; text: string; urgent: boolean; t: number }
interface Cloud { x: number; y: number; alt: number; s: number; k: number }

const SHORT_NAMES: Record<string, string> = {
  yards: 'Rail yards', steel: 'Steel works', machine: 'Machine works', chem: 'Chemical plant', 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', satW: 'Westwald', satS: 'Südhang',
  railE: 'East rail', railN: 'North rail', railS: 'South rail', lock: 'Barge lock', road: 'Convoy road',
};

const ROLE_GOAL: Record<string, string> = {
  strike: 'Bomb the target',
  escort: 'Protect the bombers — shoot down interceptors',
  weasel: 'Kill SAM radars — fire Kelts at emitting sites',
  jammer: 'Fly ahead of the bombers — your jammers blind the SAMs',
  suppress: 'Destroy the flak guns around the target',
  recon: 'Overfly and photograph the target and SAM sites',
};

const TIPS: Record<string, string> = {
  start: 'Fly toward the yellow <b>◆</b>. <kbd>←</kbd><kbd>→</kbd> turn · <kbd>↑</kbd><kbd>↓</kbd> height.',
  approach: 'Put the green <b>◎</b> on the <b>◆</b> — then <kbd>Space</kbd>.',
  sam: 'Missile! Turn the way the red arrow shows. <kbd>C</kbd> chaff.',
  flak: 'Flak walks onto straight flyers. Weave.',
  bandit: 'Red circles are enemy fighters.',
  coned: 'Searchlights! Dive out of the beam.',
  hit: 'Hit! Your aircraft picture turns orange, then red.',
  egress: 'Bombs gone — fly <b>→</b> home. <kbd>T</kbd> speeds time.',
  doors: 'Open the bomb doors first: <kbd>B</kbd>.',
  beam: 'Missiles lose you if you cross their path at a right angle.',
  fire: 'Engine fire — <kbd>E</kbd>!',
  hung: 'Bombs stuck — <kbd>B</kbd> <kbd>B</kbd> to shake them loose.',
  leak: 'Fuel leak — <kbd>K</kbd>.',
  hyd: 'Hydraulics hit — <kbd>H</kbd>.',
  tail: 'Fighter behind — hold <kbd>G</kbd> to shoot.',
  terrain: 'Hills! Watch the height bar — <kbd>↑</kbd>.',
  marker: '<kbd>V</kbd> drops a marker — the others bomb on it.',
};

export class MissionView {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private w!: World;
  private layers!: TerrainLayers;
  private fx = new FxPool();
  private sfx = new ScreenFx();
  private raf = 0;
  private last = 0;
  private timeScale = 1;
  private paused = false;
  private started = false;
  private ready = false;
  private keys = new Set<string>();
  private radioLog: RadioLine[] = [];
  private camX = 0;
  private camY = 0;
  private zoom = 30;
  private mapMode = false;
  private legend = false;
  private camBombT = 0;
  private slowmoCool = 0;
  private spectate: Plane | null = null;
  private phase: 'fly' | 'landing' | 'done' = 'fly';
  private landing = { t: 0, off: 0, vx: 0, alt: 1, gust: 0, result: '' as '' | 'ok' | 'crashlanded' | 'killed' };
  private overlay: HTMLDivElement;
  private banner: HTMLDivElement;
  private coach: HTMLDivElement;
  private touch: { stickId: number; sx: number; sy: number; dx: number; dy: number; fire: boolean; alt: boolean; gun: boolean };
  private dpr = 1;
  private vw = 0;
  private vh = 0;
  private result: MissionResult | null = null;
  private onKeyDown = (e: KeyboardEvent) => this.keyDown(e);
  private onKeyUp = (e: KeyboardEvent) => this.keys.delete(e.key.toLowerCase());
  private onResize = () => this.resize();
  private onBlur = () => { this.keys.clear(); this.resetTouch(); };
  private fireGlow = 0;
  private shownLost = false;
  private coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
  private lights: { d: string; x: number; y: number; b: number }[] = [];
  private cloudList: Cloud[] = [];
  private decoyArt = new Map<string, HTMLCanvasElement>();
  private flakNearT = -99;
  private lastStickCount = -1;
  private intensity = 0;
  private toastT = 0;
  private lastTriple = -99;
  private lastGunSound = { bofors: -9, vulcan: -9, tail: -9 };
  private flybys = new Set<number>();
  private separated = new Set<number>();
  private rwrCache: ReturnType<MissionView['rwrState']> = { searchCount: 0, locked: false, launchClose: 0, aaaGrowl: 0, threats: [] };

  constructor(
    private root: HTMLElement,
    private s: GameState,
    private plan: MissionPlan,
    private onDone: (r: MissionResult) => void,
  ) {
    this.root.replaceChildren();
    this.root.className = 'screen mission';
    this.canvas = document.createElement('canvas');
    this.canvas.className = 'mission-canvas';
    this.canvas.setAttribute('aria-label', 'Mission view');
    this.ctx = this.canvas.getContext('2d')!;
    this.overlay = document.createElement('div');
    this.overlay.className = 'mission-overlay';
    this.banner = document.createElement('div');
    this.banner.className = 'mission-banner';
    this.coach = document.createElement('div');
    this.coach.className = 'coach';
    this.coach.hidden = true;
    this.root.append(this.canvas, this.overlay, this.coach, this.banner);
    this.touch = { stickId: -1, sx: 0, sy: 0, dx: 0, dy: 0, fire: false, alt: false, gun: false };
    this.banner.innerHTML = '<div class="brief"><div class="brief-t">Preparing the theater…</div><p class="note">Painting Hafenstadt, fuelling the package.</p></div>';
    this.resize();
    window.addEventListener('keydown', this.onKeyDown);
    window.addEventListener('keyup', this.onKeyUp);
    window.addEventListener('resize', this.onResize);
    window.addEventListener('blur', this.onBlur);
    // Let the loading card paint before the heavy terrain work.
    setTimeout(() => this.setup(), 40);
  }

  private setup(): void {
    this.layers = terrainLayers();
    this.w = createWorld(this.s, this.plan);
    this.fx.windX = this.w.windX;
    this.fx.windY = this.w.windY;
    // Old craters from earlier raids.
    const sctx = this.layers.scars.getContext('2d')!;
    sctx.clearRect(0, 0, this.layers.scars.width, this.layers.scars.height);
    for (const h of this.s.raidHeat) scar(this.layers, h.x, h.y, h.kg, false);
    // City lights.
    for (const d of DISTRICTS) for (const [x, y] of districtLights(d, 7)) this.lights.push({ d: d.id, x, y, b: 0.5 + Math.random() * 0.5 });
    const r = rng(5);
    for (let i = 0; i < 900; i++) {
      const a = r() * TAU;
      const d = 5 + Math.sqrt(r()) * 14;
      this.lights.push({ d: 'sprawl', x: Math.cos(a) * d * 1.15 - 2, y: Math.sin(a) * d * 0.85, b: 0.25 + r() * 0.5 });
    }
    for (let i = 0; i < 46; i++) this.cloudList.push({ x: -70 + r() * 200, y: -90 + r() * 180, alt: 5.5 + r() * 2.5, s: 4 + r() * 6, k: Math.floor(r() * 4) });
    // Earlier waves are already airborne; the flown raid starts just short of the picket line.
    const p = this.w.player;
    if (p) {
      p.isPlayer = false; // autopilot flies the route until you take over
      const target = { x: p.flight!.aimX, y: p.flight!.aimY };
      const startD = this.plan.route === 'center' ? 9999 : 70;
      for (let i = 0; i < 6000 && !this.w.ended; i++) {
        if (p.state !== 'wait' && (p.x < BARRIER_X + CONFIG.mission.prerollToBarrierKm || Math.hypot(p.x - target.x, p.y - target.y) < startD)) break;
        step(this.w, 0.1);
        this.w.events.length = 0;
      }
      p.isPlayer = true;
      this.camX = p.x;
      this.camY = p.y;
      // Paint the ground around the start so the first frames are sharp.
      for (let j = -2; j <= 2; j++) for (let i = -3; i <= 2; i++) buildTile(Math.floor(p.x / TILE_KM) + i, Math.floor(p.y / TILE_KM) + j);
    }
    this.buildOverlay();
    this.ready = true;
    this.showBriefing();
    this.last = performance.now();
    this.raf = requestAnimationFrame((t) => this.frame(t));
  }

  destroy(): void {
    cancelAnimationFrame(this.raf);
    window.removeEventListener('keydown', this.onKeyDown);
    window.removeEventListener('keyup', this.onKeyUp);
    window.removeEventListener('resize', this.onResize);
    window.removeEventListener('blur', this.onBlur);
    audio.stopMission();
  }

  // ── Briefing, overlay, coaching ──
  private showBriefing(): void {
    const p = this.w.player;
    const role = p ? p.role : 'observer';
    const help: Record<string, string> = {
      strike: 'Fly to the target. Line the <b>green circle</b> up over the <b>yellow diamond</b> and <b>hold SPACE</b> to ripple your bombs. Your wingmen drop when you drop.',
      escort: '<b>SPACE</b> fires your cannon. <b>F</b> fires an R-3S Atoll — it needs a tone, so get behind the enemy. Hard turns bleed speed.',
      weasel: '<b>SPACE</b> fires a KSR-11 Kelt at the strongest radar in front of you. Fire while the SAM is guiding — it may switch off to dodge.',
      jammer: '<b>SPACE</b> toggles your jammers. <b>C</b> drops chaff. Stay ahead of the bombers: SAMs near you can\'t see them.',
      suppress: '<b>SPACE</b> fires an S-24 rocket salvo at the ground ahead. Kill the guns (or the picket radar) before the bombers arrive.',
      recon: 'Fly over targets and SAM sites: cameras run automatically. Nothing can catch you high and fast.',
    };
    const g = this.plan.flights.find((f) => f.id === this.plan.playerFlight);
    const target = g?.target ?? this.plan.primary;
    const d = DISTRICT_BY_ID[target];
    const pic = d ? districtPicture(d, this.s.districts[d.id].knownIntegrity, 220, 140, 'photo') : '';
    const tname = d ? d.name : target === 'pickets' ? 'Barrier early-warning radar' : target.startsWith('sam:') ? `SAM site ${target.slice(4)}` : target;
    this.banner.innerHTML = `
      <div class="brief wide">
        <div class="brief-k">${fmtTime(this.plan.launchHour)} · ${esc(ROUTES[this.plan.route].name)}</div>
        <div class="brief-t">${g ? `${esc(g.callsign)} flight · ${esc(CONFIG.aircraft[g.type].name)}` : 'Package'}</div>
        <div class="brief-pics">
          ${g ? `<figure><img src="${planeCard(g.type, 200, 120)}" alt=""><figcaption>Your aircraft</figcaption></figure>` : ''}
          ${pic ? `<figure><img src="${pic}" alt=""><figcaption>Target: ${esc(tname)}</figcaption></figure>` : `<figure class="txt"><figcaption>Target: ${esc(tname)}</figcaption></figure>`}
        </div>
        <p><b>Your job:</b> ${ROLE_GOAL[role] ?? ''}. ${help[role] ?? ''}</p>
        <ul class="brief-keys">
          <li><kbd>←</kbd><kbd>→</kbd> turn</li><li><kbd>↑</kbd><kbd>↓</kbd> climb / dive</li><li><kbd>Space</kbd> fire / bombs</li>
          <li><kbd>C</kbd> chaff</li><li><kbd>F</kbd> missile</li><li><kbd>T</kbd> faster time</li><li><kbd>L</kbd> legend</li><li><kbd>R</kbd> AI takes over</li>
          <li><kbd>B</kbd> bomb doors</li><li><kbd>Q</kbd> release mode</li><li><kbd>V</kbd> marker flare</li><li><kbd>G</kbd> tail gun (hold)</li>
          <li><kbd>E</kbd> extinguisher</li><li><kbd>H</kbd> backup hydraulics</li><li><kbd>K</kbd> isolate fuel leak</li><li><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> formation</li>
        </ul>
        <button type="button" class="primary big" data-act="go" data-tip="Take off. The raid starts a few kilometres short of the enemy picket line.">Engines — start</button>
      </div>`;
    this.banner.hidden = false;
    const go = this.banner.querySelector<HTMLButtonElement>('[data-act=go]')!;
    go.addEventListener('click', () => this.begin());
    go.focus();
  }

  private begin(): void {
    audio.start();
    audio.startMission(this.w.player?.kind ?? 'Tu-16');
    this.started = true;
    this.banner.hidden = true;
    this.banner.replaceChildren();
    this.pushRadio('CONTROL', `${this.plan.flights.length} flights inbound. Music on. Radio discipline until the line.`, false);
    this.tip('start');
  }

  private tip(key: string): void {
    const seen = (this.s.tipsSeen ??= []);
    if (seen.includes(key) || !TIPS[key]) return;
    seen.push(key);
    this.coach.innerHTML = `<span class="coach-k">TIP</span> ${TIPS[key]}`;
    this.coach.hidden = false;
    this.toastT = performance.now() + 7500;
  }

  private buildOverlay(): void {
    const btn = (label: string, cls: string, act: string, tip: string) => `<button type="button" class="tbtn ${cls}" data-act="${act}" aria-label="${label}" data-tip="${tip}">${label}</button>`;
    this.overlay.innerHTML = `
      <div class="hud-top">
        ${btn('×1', 'small', 'speed', 'Time speed (T): ×1, ×2 or ×4 — speed up the quiet parts.')}
        ${btn('MAP', 'small', 'map', 'Zoom out to the whole theater (M): SAM ranges, the package, the target.')}
        ${btn('?', 'small', 'legend', 'Legend (L): what every symbol and color on screen means.')}
        ${btn('II', 'small', 'pause', 'Pause (P).')}
        ${btn('AI', 'small', 'auto', 'Hand the rest of the raid to the AI and skip to the debrief (R).')}
      </div>
      <div class="legend" hidden>
        <b>LEGEND</b>
        <ul>
          <li><i class="lg you"></i>YOU — bracketed aircraft. Blue labels: your package</li>
          <li><i class="lg enemy"></i>Red circle + label — enemy fighter</li>
          <li><i class="lg target"></i>Yellow diamond / arrow — your target</li>
          <li><i class="lg pipper"></i>Green circle — where your bombs land if you drop now</li>
          <li><i class="lg sam"></i>Missile site with red label — SAM battery; dashed ring = its range</li>
          <li><i class="lg missile"></i>White smoke + flame — missile. Flashing box = it is chasing you</li>
          <li><i class="lg flak"></i>Black puffs — heavy flak · colored streaks — light guns and barrage</li>
          <li><i class="lg radar"></i>Tower with sweep — early-warning picket radar</li>
          <li><i class="lg glow"></i>Orange glow — fires · lit windows — districts that still have power</li>
        </ul>
        <b>RADAR WARNING (top right)</b>
        <ul><li>Your nose points up. N/B = Nike/Bloodhound radar · A = flak radar · I = fighter · S = early warning · ▲ = missile. Boxed = locked on you. Closer to the center = closer to you.</li></ul>
      </div>
      <div class="touch-stick" data-zone="stick" data-tip="Drag: left/right to turn, up/down to climb or dive."><div class="stick-knob"></div></div>
      <div class="crew-btns">
        ${btn('DOORS', '', 'doors', 'Bomb-bay doors (B). Must be open to drop. Open doors slow you and show up on radar.')}
        ${btn('MODE', '', 'mode', 'Release mode (Q): TRAIN ripples while held · SALVO drops everything at once · SINGLE one stick per press.')}
        ${btn('MARK', '', 'marker', 'Drop a pathfinder marker flare (V). Following flights bomb on it.')}
        ${btn('GUN', '', 'gun', 'Hold to man the tail turret yourself (G).')}
        ${btn('FIX', '', 'fix', 'Damage control: fire extinguisher / backup hydraulics / isolate fuel leak (E / H / K).')}
        ${btn('FORM', '', 'form', 'Formation (1/2/3): tight box (guns cover each other, bombs land together) or loose (flak hits fewer of you).')}
      </div>
      <div class="touch-btns">
        ${btn('CHAFF', '', 'chaff', 'Drop a chaff bundle: SAM radars may lock onto the cloud instead of you.')}
        ${btn('ALT', '', 'alt', 'Secondary weapon: R-3S Atoll missile (fighters).')}
        ${btn('FIRE', 'fire', 'fire', 'Hold: release bombs / fire guns / launch missiles.')}
      </div>`;
    const on = (act: string, down: boolean) => {
      if (act === 'fire') this.touch.fire = down;
      if (act === 'alt') this.touch.alt = down;
      if (act === 'chaff' && down) this.w.ctl.chaff = true;
      if (act === 'gun') this.touch.gun = down;
      if (!down) return;
      if (act === 'doors') this.w.ctl.doors = true;
      if (act === 'mode') this.w.ctl.mode = true;
      if (act === 'marker') this.w.ctl.marker = true;
      if (act === 'fix') this.fix();
      if (act === 'form') this.w.ctl.formation = this.w.crew.formation === 'normal' ? 'tight' : this.w.crew.formation === 'tight' ? 'loose' : 'normal';
      if (act === 'speed') this.cycleSpeed();
      if (act === 'map') this.mapMode = !this.mapMode;
      if (act === 'legend') this.toggleLegend();
      if (act === 'pause') this.togglePause();
      if (act === 'auto') this.handToAI();
    };
    for (const b of this.overlay.querySelectorAll<HTMLButtonElement>('button[data-act]')) {
      const act = b.dataset.act!;
      // Keep focus on the canvas so Space never "clicks" an overlay button.
      b.tabIndex = -1;
      b.addEventListener('pointerdown', (e) => { e.preventDefault(); b.setPointerCapture(e.pointerId); on(act, true); });
      b.addEventListener('pointerup', () => on(act, false));
      b.addEventListener('pointercancel', () => on(act, false));
    }
    const zone = this.overlay.querySelector<HTMLDivElement>('[data-zone=stick]')!;
    const knob = zone.querySelector<HTMLDivElement>('.stick-knob')!;
    zone.addEventListener('pointerdown', (e) => {
      e.preventDefault();
      zone.setPointerCapture(e.pointerId);
      this.touch.stickId = e.pointerId;
      const r = zone.getBoundingClientRect();
      this.touch.sx = r.left + r.width / 2;
      this.touch.sy = r.top + r.height / 2;
      this.moveStick(e, knob);
    });
    zone.addEventListener('pointermove', (e) => { if (e.pointerId === this.touch.stickId) this.moveStick(e, knob); });
    const end = (e: PointerEvent) => {
      if (e.pointerId !== this.touch.stickId) return;
      this.touch.stickId = -1;
      this.touch.dx = this.touch.dy = 0;
      knob.style.transform = '';
    };
    zone.addEventListener('pointerup', end);
    zone.addEventListener('pointercancel', end);
  }

  private toggleLegend(): void {
    this.legend = !this.legend;
    this.overlay.querySelector<HTMLElement>('.legend')!.hidden = !this.legend;
  }

  private moveStick(e: PointerEvent, knob: HTMLDivElement): void {
    const R = 46;
    let dx = (e.clientX - this.touch.sx) / R;
    let dy = (e.clientY - this.touch.sy) / R;
    const m = Math.hypot(dx, dy);
    if (m > 1) { dx /= m; dy /= m; }
    this.touch.dx = dx;
    this.touch.dy = dy;
    knob.style.transform = `translate(${dx * R}px, ${dy * R}px)`;
  }

  private resetTouch(): void {
    this.touch.fire = this.touch.alt = this.touch.gun = false;
    this.touch.dx = this.touch.dy = 0;
  }

  private keyDown(e: KeyboardEvent): void {
    const k = e.key.toLowerCase();
    if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(k)) e.preventDefault();
    if (!this.ready) return;
    if (!this.started && (k === 'enter' || k === ' ')) { this.begin(); return; }
    if (this.keys.has(k)) return;
    this.keys.add(k);
    if (k === 't') this.cycleSpeed();
    if (k === 'm') this.mapMode = !this.mapMode;
    if (k === 'l') this.toggleLegend();
    if (k === 'p' || k === 'escape') this.togglePause();
    if (k === 'c') this.w.ctl.chaff = true;
    if (k === 'r') this.handToAI();
    if (k === 'b') this.w.ctl.doors = true;
    if (k === 'q') this.w.ctl.mode = true;
    if (k === 'v') this.w.ctl.marker = true;
    if (k === 'e') this.w.ctl.extinguish = true;
    if (k === 'h') this.w.ctl.hydraulics = true;
    if (k === 'k') this.w.ctl.isolate = true;
    if (k === '1') this.w.ctl.formation = 'tight';
    if (k === '2') this.w.ctl.formation = 'normal';
    if (k === '3') this.w.ctl.formation = 'loose';
    if (k === '+' || k === '=') this.zoom *= 1.2;
    if (k === '-') this.zoom /= 1.2;
  }

  private cycleSpeed(): void {
    const opts = CONFIG.mission.timeScaleOptions;
    this.timeScale = opts[(opts.indexOf(this.timeScale) + 1) % opts.length];
    const b = this.overlay.querySelector<HTMLButtonElement>('[data-act=speed]');
    if (b) b.textContent = `×${this.timeScale}`;
  }

  private togglePause(): void {
    if (!this.started || this.phase !== 'fly') return;
    this.paused = !this.paused;
    if (this.paused) {
      this.banner.innerHTML = `<div class="brief"><div class="brief-t">Paused</div>
        <button type="button" class="primary" data-act="resume" data-tip="Back to the raid.">Resume</button>
        <button type="button" data-act="ai" data-tip="The AI flies the rest of the raid, including your aircraft.">Hand the raid to the AI</button></div>`;
      this.banner.hidden = false;
      this.banner.querySelector<HTMLButtonElement>('[data-act=resume]')!.onclick = () => this.togglePause();
      this.banner.querySelector<HTMLButtonElement>('[data-act=ai]')!.onclick = () => { this.paused = false; this.handToAI(); };
    } else {
      this.banner.hidden = true;
    }
  }

  private handToAI(): void {
    if (this.phase !== 'fly' || !this.started) return;
    this.banner.hidden = true;
    autoResolve(this.w);
    this.finish();
  }

  private pushRadio(from: string, text: string, urgent: boolean): void {
    this.radioLog.push({ from, text, urgent, t: performance.now() });
    if (this.radioLog.length > 7) this.radioLog.shift();
    audio.radio(text, urgent);
  }

  // ── Loop ──
  private resize(): void {
    this.dpr = Math.min(2, window.devicePixelRatio || 1);
    const r = this.root.getBoundingClientRect();
    this.vw = Math.max(1, r.width);
    this.vh = Math.max(1, r.height);
    this.canvas.width = Math.round(this.vw * this.dpr);
    this.canvas.height = Math.round(this.vh * this.dpr);
  }

  private frame(now: number): void {
    this.raf = requestAnimationFrame((t) => this.frame(t));
    const dt = Math.min(0.05, (now - this.last) / 1000);
    this.last = now;
    if (this.vw !== this.root.clientWidth || this.vh !== this.root.clientHeight) this.resize();
    if (this.toastT && now > this.toastT) { this.coach.hidden = true; this.toastT = 0; }
    if (this.phase === 'landing') {
      this.landingStep(dt);
      this.drawLanding();
      return;
    }
    if (this.phase === 'done') return;
    if (this.started && !this.paused) {
      const slow = this.sfx.slowmo > 0 && prefs.motion > 0.5 ? CONFIG.fx.slowmoScale : 1;
      const simDt = dt * this.timeScale * slow * CONFIG.mission.pace;
      this.readControls();
      const sub = Math.ceil(simDt / 0.034);
      for (let i = 0; i < sub; i++) step(this.w, simDt / sub);
      this.consumeEvents();
      this.proximity();
      const lp = this.w.player && this.w.player.alive ? this.w.player : this.spectate;
      if (lp) audio.setListener(lp.x, lp.y, lp.hdg);
      this.fx.update(dt * slow * Math.min(this.timeScale, 2) * CONFIG.mission.pace);
      for (const c of this.cloudList) { c.x += this.w.windX * dt * 4; c.y += this.w.windY * dt * 4; }
      this.sfx.update(dt);
      this.slowmoCool -= dt;
      this.updateAudio(dt);
      this.checkFlow();
    }
    this.draw(dt);
  }

  private readControls(): void {
    const k = this.keys;
    const c = this.w.ctl;
    let turn = (k.has('arrowright') || k.has('d') ? 1 : 0) - (k.has('arrowleft') || k.has('a') ? 1 : 0);
    let climb = (k.has('arrowup') || k.has('w') ? 1 : 0) - (k.has('arrowdown') || k.has('s') ? 1 : 0);
    if (this.touch.stickId >= 0) {
      turn = this.touch.dx;
      climb = -this.touch.dy;
    }
    c.steerX = 0;
    c.steerY = 0;
    if (this.w.guidedBomb?.alive && this.w.player) {
      // While guiding the UPAB the arrows move the TV aim point; the aircraft holds course.
      const p = this.w.player;
      const fx = Math.cos(p.hdg);
      const fy = Math.sin(p.hdg);
      c.steerX = turn * -fy + climb * fx;
      c.steerY = turn * fx + climb * fy;
      turn = 0;
      climb = 0;
    }
    c.turn = turn;
    c.climb = climb;
    c.throttle = (k.has('shift') ? 1 : 0) - (k.has('control') || k.has('z') ? 1 : 0);
    c.fire = k.has(' ') || this.touch.fire;
    c.secondary = k.has('f') || k.has('x') || this.touch.alt;
    c.tailGun = k.has('g') || this.touch.gun;
  }

  /** Touch damage-control button: deal with the most urgent emergency. */
  private fix(): void {
    const p = this.w.player;
    if (!p) return;
    if (p.engineFire > 0) this.w.ctl.extinguish = true;
    else if (this.w.crew.fuelLeak > 0 && !this.w.crew.leakIsolated) this.w.ctl.isolate = true;
    else if (p.hydraulics > 0.15 && this.w.crew.hydBackup) this.w.ctl.hydraulics = true;
  }

  private checkFlow(): void {
    const p = this.w.player;
    if (p && !p.alive && !this.shownLost) {
      this.shownLost = true;
      const fate = p.fate === 'captured' ? 'Parachutes over enemy ground — the crew will be captured.' : p.fate === 'ejectedHome' ? 'The crew bailed out over friendly lines.' : 'No parachutes were seen.';
      this.toast(`YOUR AIRCRAFT IS LOST — ${fate} The raid continues; you are watching your package.`, 6000);
      this.sfx.flash = 1;
      this.sfx.vignette = 1;
    }
    if (p && p.alive && !p.done) {
      const fr = p.flight!;
      const d = Math.hypot(fr.aimX - p.x, fr.aimY - p.y);
      if (d < 26) this.tip('approach');
      if (p.searchlit > 0) this.tip('coned');
      if (p.hp < p.maxHp * 0.85) this.tip('hit');
      if (p.engineFire > 0) this.tip('fire');
      if (p.hydraulics > 0.15 && this.w.crew.hydBackup) this.tip('hyd');
      if (this.w.crew.beam > 0 || this.rwrCache.launchClose > 0.1) this.tip('beam');
      if (this.w.crew.tailTarget >= 0) this.tip('tail');
      if (this.w.crew.pullUp) this.tip('terrain');
      if (d < 40 && p.sticks > 0 && p.role === 'strike' && this.w.crew.doors === 0) this.tip('doors');
      if (d < 34 && p.role === 'strike' && this.w.crew.markers > 0 && this.plan.flights.filter((f) => f.role === 'strike').length > 1) this.tip('marker');
      if (this.lastStickCount > 0 && p.sticks === 0 && p.munition) {
        this.pushRadio(p.callsign, 'Winchester — all stores gone. Turning for home.', false);
        this.tip('egress');
      }
      this.lastStickCount = p.sticks;
    }
    if (p && p.done && p.alive && this.timeScale < 4 && !this.w.ended) {
      this.timeScale = 4;
      this.toast('Feet wet — crossing the line home. Time ×4. Press AI to skip to the debrief.', 4000);
    }
    if (this.w.ended) this.finish();
  }

  private toast(text: string, ms: number): void {
    const el = document.createElement('div');
    el.className = 'mission-toast';
    el.textContent = text;
    this.root.append(el);
    setTimeout(() => el.remove(), ms);
  }

  private finish(): void {
    if (this.phase !== 'fly') return;
    const p = this.w.player;
    this.result = finishWorld(this.w);
    if (p && p.alive && !this.w.autoResolved && p.hp / p.maxHp < 0.5) {
      this.phase = 'landing';
      this.landing = { t: 0, off: (Math.random() - 0.5) * 0.6, vx: 0, alt: 1, gust: 0, result: '' };
      this.banner.innerHTML = `<div class="brief"><div class="brief-t">Battle-damage landing</div><p>${Math.round((1 - p.hp / p.maxHp) * 100)}% airframe damage. The aircraft pulls to one side. Hold the runway centerline with ←/→ and touch down straight.</p><button type="button" class="primary" data-act="land" data-tip="Start the approach. Keep the aircraft over the dashed centerline.">Begin approach</button></div>`;
      this.banner.hidden = false;
      this.paused = true;
      this.banner.querySelector<HTMLButtonElement>('[data-act=land]')!.onclick = () => { this.banner.hidden = true; this.paused = false; };
      return;
    }
    this.complete();
  }

  private complete(): void {
    this.phase = 'done';
    audio.stopMission();
    if (this.result) this.onDone(this.result);
  }

  // ── Events → effects, audio, radio ──
  private consumeEvents(): void {
    const p = this.w.player;
    const px = p?.x ?? this.camX;
    const py = p?.y ?? this.camY;
    const night = this.w.light < 0.35;
    for (const e of this.w.events) {
      switch (e.t) {
        case 'flak': {
          this.fx.flakBurst(e.x, e.y, e.alt, e.kind === 'heavy');
          const d = Math.hypot(e.x - px, e.y - py);
          if (e.near < 1.4) {
            this.sfx.kick(CONFIG.fx.shakeFlakNear * (1.6 - e.near));
            this.flakNearT = this.w.t;
            if (p && p.alt < 1) this.sfx.dirt = Math.min(1, this.sfx.dirt + 0.3);
            this.tip('flak');
            if (this.w.t - this.lastTriple > 14 && p?.alive) {
              this.lastTriple = this.w.t;
              this.pushRadio(p.flight?.planes.find((q) => q !== p && q.alive)?.callsign ?? 'NAVIGATOR', `Triple-A, ${this.clockOf(e.x, e.y)} o'clock — they're walking it onto us!`, true);
            }
          }
          if (d < 16 && (e.near < 1.5 || Math.random() < 0.55)) audio.flak(e.x, e.y, e.near, e.kind === 'heavy');
          break;
        }
        case 'tracer': {
          const dx = e.tx - e.x;
          const dy = e.ty - e.y;
          const n = e.onPlayer ? 5 : 3;
          for (let i = 0; i < n; i++) {
            const f = 0.1 + Math.random() * 0.8;
            const sx = e.x + dx * f;
            const sy = e.y + dy * f;
            this.fx.spawn('tracer', sx, sy, { x2: sx + dx * 0.09, y2: sy + dy * 0.09, vx: dx * 0.6, vy: dy * 0.6, life: 0.4, alt: e.alt * f, hue: e.onPlayer ? 1 : 0 });
          }
          if (e.onPlayer) {
            this.sfx.ambientShake = Math.min(4, this.sfx.ambientShake + 0.25);
            const now = this.w.t;
            if (e.gun === 'bofors' && now - this.lastGunSound.bofors > 1) { this.lastGunSound.bofors = now; audio.bofors(e.x, e.y); }
            if (e.gun === 'vulcan' && now - this.lastGunSound.vulcan > 1.3) { this.lastGunSound.vulcan = now; audio.vulcan(e.x, e.y); }
          }
          break;
        }
        case 'barrage': {
          // A stream of rounds climbing out of the city.
          const dx = e.tx - e.x;
          const dy = e.ty - e.y;
          for (let i = 0; i < 5; i++) {
            const f = i / 5 + Math.random() * 0.1;
            const sx = e.x + dx * f;
            const sy = e.y + dy * f;
            this.fx.spawn('tracer', sx, sy, { x2: sx + dx * 0.07, y2: sy + dy * 0.07, vx: dx * 0.35, vy: dy * 0.35, life: 0.6 + Math.random() * 0.4, alt: e.alt * f, hue: 3 + e.hue });
          }
          break;
        }
        case 'samLaunch':
          this.fx.launchCloud(e.x, e.y);
          audio.samLaunch(e.x, e.y, e.m.kind === 'nike');
          if (e.m.targetId === p?.id) this.tip('sam');
          break;
        case 'missileDet':
          this.fx.missileDet(e.x, e.y, e.alt);
          if (e.near < 2.5) {
            this.sfx.kick(CONFIG.fx.shakeSamNear * (2.5 - e.near) / 2);
            if (e.near < 1.5 && this.slowmoCool <= 0) { this.sfx.slowmo = CONFIG.fx.slowmoSeconds; this.slowmoCool = 3; }
            this.sfx.flash = Math.max(this.sfx.flash, 0.4 * (2.5 - e.near));
          }
          audio.boomAt(e.x, e.y, 0.7);
          break;
        case 'impact': {
          const inc = CONFIG.munitions[e.munition].fire > 0.5;
          this.fx.bombBlast(e.x, e.y, e.kg, inc, night);
          scar(this.layers, e.x, e.y, e.kg, inc);
          const d = Math.hypot(e.x - px, e.y - py);
          audio.boomAt(e.x, e.y, Math.min(1.2, e.kg / 1500 + 0.3));
          if (d < 4) this.sfx.kick(CONFIG.fx.shakeBomb * (4 - d));
          if (e.player && this.slowmoCool <= 0) { this.sfx.slowmo = CONFIG.fx.slowmoSeconds; this.slowmoCool = 4; }
          break;
        }
        case 'secondary':
          this.fx.secondary(e.x, e.y, e.big);
          audio.boomAt(e.x, e.y, e.big ? 1.4 : 0.8);
          if (e.big) this.pushRadioThrottled('RECON', 'Secondaries! Big secondaries — something is burning down there!');
          break;
        case 'picketDown':
          this.fx.secondary(e.x, e.y, false);
          break;
        case 'hit':
          if (e.plane.isPlayer) {
            this.sfx.vignette = Math.min(1, this.sfx.vignette + CONFIG.fx.vignettePulse);
            this.sfx.kick(5 + e.dmg * 0.5);
            if (['flak90', 'bofors', 'barrage', 'nike', 'bloodhound'].includes(e.plane.lastSrc)) audio.shrapnel(Math.min(1, e.dmg / 20));
            else audio.clang(Math.min(0.6, 0.15 + e.dmg / 40));
          }
          for (let i = 0; i < 4; i++) this.fx.spawn('spark', e.plane.x, e.plane.y, { vx: (Math.random() - 0.5) * 1.8, vy: (Math.random() - 0.5) * 1.8, life: 0.35, size: 0.05, alt: e.plane.alt });
          break;
        case 'down':
          this.fx.planeDown(e.plane.x, e.plane.y, e.plane.alt, e.plane.chuteCount);
          audio.boomAt(e.plane.x, e.plane.y, 0.8);
          if (e.plane.side === 'blue') this.tip('bandit');
          break;
        case 'chaff':
          this.fx.chaffBloom(e.x, e.y, e.alt, p?.hdg ?? 0);
          break;
        case 'release':
          if (e.plane.isPlayer) {
            this.sfx.flash = Math.max(this.sfx.flash, CONFIG.fx.whiteFlash);
            this.camBombT = 1.4;
          }
          break;
        case 'gun':
          if (e.player) audio.gun(0.2);
          this.fx.spawn('tracer', e.x, e.y, { x2: e.x + Math.cos(e.hdg) * 0.3, y2: e.y + Math.sin(e.hdg) * 0.3, vx: Math.cos(e.hdg) * 2.6, vy: Math.sin(e.hdg) * 2.6, life: 0.3, hue: e.player ? 2 : 1 });
          break;
        case 'blackout':
          this.pushRadioThrottled('RECON', `Lights going out over ${DISTRICT_BY_ID[e.district].name}.`);
          break;
        case 'sys':
          this.onSys(e.what, e.x, e.y, e.v ?? 0);
          break;
        case 'radio':
          this.pushRadio(e.from, e.text, e.urgent);
          if (/bandit/i.test(e.text)) this.tip('bandit');
          break;
      }
    }
    this.w.events.length = 0;
    this.sfx.ambientShake = Math.max(0, this.sfx.ambientShake - 0.05);
    // Missile smoke, contrails, damage smoke.
    for (const m of this.w.missiles) {
      this.fx.spawn('trail', m.x, m.y, { life: m.side === 'blue' ? 8 : 3.5, size: m.kind === 'nike' ? 0.12 : 0.08, grow: 0.06, alt: m.alt });
    }
    for (const q of this.w.planes) {
      if (!q.alive || q.done || q.state === 'wait') continue;
      q.trail -= 1;
      if (q.trail > 0) continue;
      q.trail = 3;
      const dmg = 1 - q.hp / q.maxHp;
      if (q.alt > 7 && q.kind !== 'drone') this.fx.spawn('contrail', q.x - Math.cos(q.hdg) * 0.4, q.y - Math.sin(q.hdg) * 0.4, { life: 7, size: 0.05, grow: 0.03, alt: q.alt });
      if (q.engineFire > 0) this.fx.spawn('fire', q.x, q.y, { life: 0.6, size: 0.12, alt: q.alt });
      if (dmg > 0.3) this.fx.spawn('smoke', q.x, q.y, { life: 5, size: 0.08, grow: 0.07, alt: q.alt });
      if (q.hydraulics > 0.2 && q.isPlayer) this.fx.spawn('trail', q.x, q.y, { life: 2.5, size: 0.03, hue: 2, alt: q.alt });
    }
    // Fires throw smoke and embers.
    const g = this.w.fire;
    for (let i = 0; i < 30; i++) {
      const k = Math.floor(Math.random() * g.heat.length);
      if (g.heat[k] < 0.15) continue;
      const x = g.x0 + (k % g.w + Math.random()) * g.cell;
      const y = g.y0 + (Math.floor(k / g.w) + Math.random()) * g.cell;
      if (Math.random() < 0.65) this.fx.spawn('smoke', x, y, { life: 12 + Math.random() * 12, size: 0.18, grow: 0.1 * g.heat[k], hue: 1 });
      else this.fx.spawn('ember', x, y, { vx: (Math.random() - 0.5) * 0.35, vy: (Math.random() - 0.5) * 0.35, life: 1.4, size: 0.05 });
    }
  }

  private onSys(what: string, x: number, y: number, v: number): void {
    const p = this.w.player;
    switch (what) {
      case 'doors': audio.doors(v === 1); break;
      case 'clunk': audio.clunk(); this.sfx.kick(1.5); break;
      case 'hung': audio.clang(0.3); this.tip('hung'); break;
      case 'unhung': for (let i = 0; i < Math.min(4, v); i++) setTimeout(() => audio.clunk(), i * 90); break;
      case 'extinguish': audio.extinguisher(); this.fx.spawn('trail', x, y, { life: 3, size: 0.12, grow: 0.15, alt: p?.alt ?? 0 }); break;
      case 'engineBoom':
        audio.boomAt(x, y, 0.6);
        audio.clang(0.6);
        this.fx.planeDown(x, y, p?.alt ?? 0, 0);
        this.sfx.kick(16);
        this.sfx.flash = 0.6;
        break;
      case 'hydBackup': audio.thud(90, 0.3, 0.06); break;
      case 'isolate': audio.thud(110, 0.25, 0.04); break;
      case 'leak': this.tip('leak'); break;
      case 'marker':
        this.fx.spawn('flash', x, y, { life: 0.5, size: 1.2 });
        audio.boomAt(x, y, 0.15);
        break;
      case 'beamLost': this.fx.spawn('flash', x, y, { life: 0.2, size: 0.4, alt: p?.alt ?? 0 }); break;
      case 'flameout': audio.spoolDown(); break;
      case 'crash':
        this.fx.secondary(x, y, true);
        audio.boomAt(x, y, 1.2);
        this.sfx.kick(28);
        this.sfx.flash = 1;
        break;
      case 'tailgun':
        if (this.w.t - this.lastGunSound.tail > 0.15) { this.lastGunSound.tail = this.w.t; audio.gun(0.22, true); }
        break;
    }
  }

  /** Sounds of things racing past you, and the booster dropping off each Nike. */
  private proximity(): void {
    const p = this.w.player;
    if (p && p.alive && !p.done) {
      for (const q of this.w.planes) {
        if (q.side !== 'blue' || !q.alive || q.done || this.flybys.has(q.id)) continue;
        if (Math.hypot(q.x - p.x, q.y - p.y) < 1.1 && Math.abs(q.alt - p.alt) < 1.5) {
          this.flybys.add(q.id);
          setTimeout(() => this.flybys.delete(q.id), 6000);
          audio.flyby(q.x, q.y, 0.5, false);
          this.sfx.kick(4);
        }
      }
      for (const m of this.w.missiles) {
        if (m.side !== 'blue' || this.flybys.has(-m.id)) continue;
        if (Math.hypot(m.x - p.x, m.y - p.y) < 1.2 && Math.abs(m.alt - p.alt) < 1.5) {
          this.flybys.add(-m.id);
          audio.flyby(m.x, m.y, 0.6, true);
        }
      }
    }
    for (const q of this.fx.items) {
      if (q.on && q.kind === 'booster' && Math.random() < 0.5) this.fx.spawn('trail', q.x, q.y, { life: 4, size: 0.07, grow: 0.05, alt: q.alt });
    }
    for (const mk of this.w.markers) {
      if (Math.random() < 0.3) this.fx.spawn('smoke', mk.x, mk.y, { life: 9, size: 0.12, grow: 0.07, hue: 0 });
    }
    for (const m of this.w.missiles) {
      if (m.kind === 'nike' && m.age > 3.4 && !this.separated.has(m.id)) {
        this.separated.add(m.id);
        this.fx.boosterDrop(m.x, m.y, m.alt, m.hdg);
      }
    }
  }

  private clockOf(x: number, y: number): number {
    const p = this.w.player;
    if (!p) return 12;
    const rel = wrap(Math.atan2(y - p.y, x - p.x) - p.hdg);
    let c = Math.round((rel / TAU) * 12);
    if (c <= 0) c += 12;
    return c;
  }

  private lastThrottled: Record<string, number> = {};
  private pushRadioThrottled(from: string, text: string): void {
    const now = performance.now();
    if ((this.lastThrottled[from + text.slice(0, 12)] ?? 0) > now - 8000) return;
    this.lastThrottled[from + text.slice(0, 12)] = now;
    this.pushRadio(from, text, false);
  }

  private rwrState() {
    const p = this.w.player;
    const out = { searchCount: 0, locked: false, launchClose: 0, aaaGrowl: 0, threats: [] as { b: number; d: number; kind: string; lock: boolean; launch: boolean }[] };
    if (!p || !p.alive) return out;
    for (const s of this.w.sites) {
      if (!s.emitting) continue;
      const d = Math.hypot(s.st.x - p.x, s.st.y - p.y);
      if (d > CONFIG.sam[s.st.kind].searchRange) continue;
      out.searchCount++;
      const lock = s.targetId === p.id && (s.state === 'track' || s.state === 'guide');
      if (lock) out.locked = true;
      out.threats.push({ b: Math.atan2(s.st.y - p.y, s.st.x - p.x), d, kind: s.st.kind === 'nike' ? 'N' : 'B', lock, launch: lock && s.state === 'guide' });
    }
    if (this.w.detected) {
      for (const pk of this.w.pickets) {
        if (!pk.alive) continue;
        const d = Math.hypot(pk.x - p.x, pk.y - p.y);
        if (d < 55) { out.searchCount++; out.threats.push({ b: Math.atan2(pk.y - p.y, pk.x - p.x), d, kind: 'S', lock: false, launch: false }); }
      }
    }
    for (const m of this.w.missiles) {
      if (m.side !== 'blue' || m.targetId !== p.id) continue;
      const d = Math.hypot(m.x - p.x, m.y - p.y);
      out.launchClose = Math.max(out.launchClose, clamp(1 - d / 22));
      out.threats.push({ b: Math.atan2(m.y - p.y, m.x - p.x), d, kind: m.kind === 'aim9' || m.kind === 'aim7' ? 'M' : '▲', lock: true, launch: true });
    }
    let growl = 0;
    for (const g of this.w.guns) {
      if (!g.alive || g.targetId !== p.id || g.kind !== 'heavy') continue;
      const d = Math.hypot(g.x - p.x, g.y - p.y);
      if (d < CONFIG.aaa.radarAAAgrowlRange) { growl += 0.25; if (out.threats.length < 30) out.threats.push({ b: Math.atan2(g.y - p.y, g.x - p.x), d, kind: 'A', lock: false, launch: false }); }
    }
    out.aaaGrowl = clamp(growl);
    for (const q of this.w.planes) {
      if (q.side !== 'blue' || !q.alive || q.done) continue;
      const d = Math.hypot(q.x - p.x, q.y - p.y);
      if (d < 20) out.threats.push({ b: Math.atan2(q.y - p.y, q.x - p.x), d, kind: 'I', lock: q.targetId === p.id, launch: false });
    }
    return out;
  }

  private updateAudio(dt: number): void {
    const p = this.w.player;
    this.rwrCache = this.rwrState();
    const r = this.rwrCache;
    const distCity = p ? Math.hypot(p.x + 2, p.y) : 99;
    const bandits = this.w.planes.filter((q) => q.side === 'blue' && q.alive && !q.done && p && Math.hypot(q.x - p.x, q.y - p.y) < 15).length;
    const goal = clamp(r.searchCount * 0.08 + (r.locked ? 0.3 : 0) + r.launchClose * 0.5 + r.aaaGrowl * 0.3 + bandits * 0.1 + (distCity < 20 && this.w.detected ? 0.25 : 0));
    this.intensity += (goal - this.intensity) * Math.min(1, dt * 1.5);
    audio.update(dt, {
      engine: p?.alive ? 1 : 0.3,
      speed: p?.spd ?? 0.8,
      lowAlt: (p?.alt ?? 5) < 1,
      searchCount: r.searchCount,
      locked: r.locked,
      launchClose: r.launchClose,
      aaaGrowl: r.aaaGrowl,
      siren: this.w.detected && p ? clamp(1 - distCity / 25) * (p.alt < 4 ? 1 : 0.4) : 0,
      critical: p?.alive ? clamp((0.35 - p.hp / p.maxHp) / 0.35) : 0,
      engineFire: (p?.engineFire ?? 0) > 0,
      intensity: this.intensity,
      damage: p?.alive ? 1 - p.hp / p.maxHp : 0,
      doors: this.w.crew.doors,
      turn: this.w.ctl.turn,
      throttle: this.w.ctl.throttle,
      flamedOut: this.w.crew.flamedOut,
      pullUp: this.w.crew.pullUp && !!p?.alive,
    });
  }

  // ── Drawing ──
  private altPx(alt: number, z: number): number {
    return alt * Math.min(9, z * 0.13);
  }

  private draw(dt: number): void {
    const ctx = this.ctx;
    const W = this.vw;
    const H = this.vh;
    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
    if (!this.ready) {
      ctx.fillStyle = '#03080a';
      ctx.fillRect(0, 0, W, H);
      return;
    }
    const p = this.w.player;
    let follow: { x: number; y: number; alt: number } | null = p && p.alive && !p.done ? p : null;
    if (!follow) {
      if (!this.spectate || !this.spectate.alive || this.spectate.done) this.spectate = this.w.planes.find((q) => q.side === 'red' && q.alive && !q.done && q.state !== 'wait' && q.kind !== 'drone') ?? null;
      follow = this.spectate;
    }
    if (this.camBombT > 0 && this.w.bombs.length && p) {
      this.camBombT -= dt;
      const b = this.w.bombs.find((q) => q.owner === p) ?? this.w.bombs[0];
      follow = { x: b.x, y: b.y, alt: 0 };
    }
    if (follow) {
      const lead = p && follow === p ? 2.5 + p.spd * 2 : 0;
      const tx = follow.x + (p && follow === p ? Math.cos(p.hdg) * lead : 0);
      const ty = follow.y + (p && follow === p ? Math.sin(p.hdg) * lead : 0);
      this.camX += (tx - this.camX) * Math.min(1, dt * 3.5);
      this.camY += (ty - this.camY) * Math.min(1, dt * 3.5);
    }
    const base = Math.min(W, H) / (this.mapMode ? 120 : 11 + (p?.alt ?? 4) * 1.0);
    const z = this.mapMode ? base : base * (this.zoom / 30);
    const [sx, sy] = this.sfx.offset();
    const cx = W / 2 + sx;
    const cy = H / 2 + sy;
    const X = (x: number) => cx + (x - this.camX) * z;
    const Y = (y: number, alt = 0) => cy + (y - this.camY) * z - this.altPx(alt, z);
    const night = 1 - this.w.light;

    // ── Ground: painted theater, city detail, craters ──
    ctx.fillStyle = '#20281a';
    ctx.fillRect(0, 0, W, H);
    ctx.save();
    ctx.setTransform(this.dpr * z, 0, 0, this.dpr * z, this.dpr * (cx - this.camX * z), this.dpr * (cy - this.camY * z));
    const L = this.layers;
    ctx.drawImage(L.world, L.worldBox.x0, L.worldBox.y0, L.world.width / L.worldBox.s, L.world.height / L.worldBox.s);
    if (z >= 14) {
      // Crisp detail tiles around the camera, painted on demand.
      let budget = 2;
      const ti0 = Math.floor((this.camX - W / 2 / z) / TILE_KM);
      const ti1 = Math.floor((this.camX + W / 2 / z) / TILE_KM);
      const tj0 = Math.floor((this.camY - H / 2 / z - 10) / TILE_KM);
      const tj1 = Math.floor((this.camY + H / 2 / z) / TILE_KM);
      for (let j = tj0; j <= tj1; j++) {
        for (let i = ti0; i <= ti1; i++) {
          let t = cachedTile(i, j);
          if (!t && budget > 0) { t = buildTile(i, j); budget--; }
          if (t) ctx.drawImage(t, i * TILE_KM, j * TILE_KM, TILE_KM + 0.02, TILE_KM + 0.02);
          else if (Math.abs(i * TILE_KM + 4 - L.cityBox.x0 - 40) < 44 && Math.abs(j * TILE_KM + 4 - L.cityBox.y0 - 35) < 39) ctx.drawImage(L.city, (i * TILE_KM - L.cityBox.x0) * L.cityBox.s, (j * TILE_KM - L.cityBox.y0) * L.cityBox.s, TILE_KM * L.cityBox.s, TILE_KM * L.cityBox.s, i * TILE_KM, j * TILE_KM, TILE_KM, TILE_KM);
        }
      }
    } else if (z > 4) ctx.drawImage(L.city, L.cityBox.x0, L.cityBox.y0, L.city.width / L.cityBox.s, L.city.height / L.cityBox.s);
    ctx.drawImage(L.scars, L.cityBox.x0, L.cityBox.y0, L.scars.width / L.cityBox.s, L.scars.height / L.cityBox.s);
    // Decoys: identical plywood copies.
    for (const d of DISTRICTS) {
      for (const dec of this.s.districts[d.id].decoys) {
        if (dec.destroyed) continue;
        let art = this.decoyArt.get(dec.id);
        if (!art) {
          art = document.createElement('canvas');
          const sc = 26;
          art.width = Math.ceil(d.w * 0.8 * sc + 4);
          art.height = Math.ceil(d.h * 0.8 * sc + 4);
          const a = art.getContext('2d')!;
          a.setTransform(sc, 0, 0, sc, art.width / 2 - dec.x * sc, art.height / 2 - dec.y * sc);
          paintDistrict(a, d, dec.x, dec.y, 1968, 0.8);
          this.decoyArt.set(dec.id, art);
        }
        ctx.drawImage(art, dec.x - art.width / 52, dec.y - art.height / 52, art.width / 26, art.height / 26);
      }
    }
    // Destruction darkens districts into rubble.
    for (const d of DISTRICTS) {
      const st = this.s.districts[d.id];
      if (st.integrity > 0.95) continue;
      ctx.fillStyle = `rgba(25,18,12,${(1 - st.integrity) * 0.55})`;
      ctx.fillRect(d.x - d.w / 2, d.y - d.h / 2, d.w, d.h);
    }
    ctx.restore();

    // ── Time-of-day grading ──
    if (night > 0.02) {
      ctx.fillStyle = `rgba(4,8,24,${0.86 * night})`;
      ctx.fillRect(0, 0, W, H);
    }
    if (this.w.light > 0.2 && this.w.light < 0.95) {
      ctx.fillStyle = `rgba(255,130,60,${0.14 * (1 - Math.abs(this.w.light - 0.55) * 2)})`;
      ctx.fillRect(0, 0, W, H);
    }

    // ── City lights die district by district ──
    if (night > 0.3) {
      ctx.globalCompositeOperation = 'lighter';
      const lamp = glow(255, 180, 90, 0.2);
      const sz = Math.max(2, Math.min(7, z * 0.12));
      for (const Lt of this.lights) {
        const powered = Lt.d === 'sprawl' ? this.s.districts.subN.powered || this.s.districts.subS.powered : this.s.districts[Lt.d].powered && this.s.districts[Lt.d].integrity > 0.2;
        if (!powered) continue;
        const x = X(Lt.x);
        const y = Y(Lt.y);
        if (x < -10 || y < -10 || x > W + 10 || y > H + 10) continue;
        ctx.globalAlpha = night * Lt.b * (this.w.detected ? 0.5 : 1);
        ctx.drawImage(lamp, x - sz, y - sz, sz * 2, sz * 2);
      }
      ctx.globalAlpha = 1;
      ctx.globalCompositeOperation = 'source-over';
    }

    // ── Fire grid ──
    const g = this.w.fire;
    ctx.globalCompositeOperation = 'lighter';
    let glowSum = 0;
    const cs = g.cell * z;
    const fireSprite = glow(255, 110, 30, 0.25);
    const i0 = Math.max(0, Math.floor((this.camX - W / 2 / z - g.x0) / g.cell));
    const i1 = Math.min(g.w - 1, Math.ceil((this.camX + W / 2 / z - g.x0) / g.cell));
    const j0 = Math.max(0, Math.floor((this.camY - H / 2 / z - g.y0) / g.cell));
    const j1 = Math.min(g.h - 1, Math.ceil((this.camY + H / 2 / z - g.y0) / g.cell));
    for (let j = j0; j <= j1; j++) {
      for (let i = i0; i <= i1; i++) {
        const h = g.heat[j * g.w + i];
        if (h < 0.05) continue;
        glowSum += h;
        const x = X(g.x0 + (i + 0.5) * g.cell);
        const y = Y(g.y0 + (j + 0.5) * g.cell);
        const r = cs * (1.3 + h * 1.6) * (0.85 + Math.random() * 0.3);
        ctx.globalAlpha = clamp(h * (0.35 + night * 0.55));
        ctx.drawImage(fireSprite, x - r, y - r, r * 2, r * 2);
      }
    }
    ctx.globalAlpha = 1;
    this.fireGlow += (glowSum - this.fireGlow) * 0.05;
    ctx.globalCompositeOperation = 'source-over';

    // Pathfinder marker flares: green fire on the aiming point.
    for (const mk of this.w.markers) {
      const x = X(mk.x);
      const y = Y(mk.y);
      const r = (16 + Math.sin(performance.now() / 90) * 4) * (0.6 + Math.min(1, mk.life / 20) * 0.4);
      ctx.globalCompositeOperation = 'lighter';
      ctx.drawImage(glow(90, 255, 120, 0.3), x - r * 2, y - r * 2, r * 4, r * 4);
      ctx.globalCompositeOperation = 'source-over';
      ctx.fillStyle = '#b8ffc8';
      ctx.font = '700 10px ui-monospace, monospace';
      ctx.textBaseline = 'middle';
      ctx.fillText('MARKER', x + r + 4, y);
    }
    this.drawGroundDefenses(X, Y, z, W, H, night);

    // ── Searchlights ──
    if (this.w.lights.length) {
      ctx.globalCompositeOperation = 'lighter';
      for (const l of this.w.lights) {
        const x0 = X(l.x);
        const y0 = Y(l.y);
        const ex = l.x + Math.cos(l.ang) * l.len;
        const ey = l.y + Math.sin(l.ang) * l.len;
        const x1 = X(ex);
        const y1 = Y(ey, 4);
        const grad = ctx.createLinearGradient(x0, y0, x1, y1);
        const a = l.targetId >= 0 ? 0.42 : 0.2;
        grad.addColorStop(0, `rgba(210,225,255,${a})`);
        grad.addColorStop(1, 'rgba(210,225,255,0)');
        ctx.fillStyle = grad;
        const nx = -(y1 - y0);
        const ny = x1 - x0;
        const nl = Math.hypot(nx, ny) || 1;
        const wdt = 0.7 * z;
        ctx.beginPath();
        ctx.moveTo(x0, y0);
        ctx.lineTo(x1 + (nx / nl) * wdt, y1 + (ny / nl) * wdt);
        ctx.lineTo(x1 - (nx / nl) * wdt, y1 - (ny / nl) * wdt);
        ctx.closePath();
        ctx.fill();
        ctx.globalAlpha = 0.8;
        ctx.drawImage(glow(220, 230, 255), x0 - 5, y0 - 5, 10, 10);
        ctx.globalAlpha = 1;
      }
      ctx.globalCompositeOperation = 'source-over';
    }

    // ── Shadows ──
    for (const q of this.w.planes) {
      if (!q.alive || q.done || q.state === 'wait') continue;
      const size = this.planeSize(q, z);
      const a = 0.32 * (1 - q.alt / 16) * (1 - night * 0.7);
      if (a < 0.03) continue;
      ctx.globalAlpha = a;
      this.drawRot(planeShadow(q.kind), X(q.x + q.alt * 0.12), Y(q.y + q.alt * 0.08), q.hdg, size * 0.95, q.bank);
    }
    ctx.globalAlpha = 1;

    this.drawParticles(X, Y, z, false);

    // ── Bombs ──
    for (const b of this.w.bombs) {
      const f = b.t / b.ft;
      const alt = b.alt0 * (1 - f * f);
      const x = X(b.x);
      const y = Y(b.y, alt);
      ctx.fillStyle = '#2b2b28';
      ctx.beginPath();
      ctx.ellipse(x, y, Math.max(1.5, 3.2 - f * 1.8), Math.max(1, 1.8 - f), Math.atan2(b.vy, b.vx), 0, TAU);
      ctx.fill();
      if (b.guided) {
        ctx.globalCompositeOperation = 'lighter';
        ctx.drawImage(glow(140, 255, 170), x - 5, y - 5, 10, 10);
        ctx.globalCompositeOperation = 'source-over';
      }
    }

    // ── Aircraft below the clouds, clouds, aircraft above ──
    const cloudAlt = 6.5;
    this.drawPlanes(X, Y, z, (q) => q.alt < cloudAlt, night);
    this.drawMissiles(X, Y, z, (alt) => alt < cloudAlt);
    this.drawClouds(X, Y, z, night);
    this.drawPlanes(X, Y, z, (q) => q.alt >= cloudAlt, night);
    this.drawMissiles(X, Y, z, (alt) => alt >= cloudAlt);
    this.drawParticles(X, Y, z, true);

    // Speed lines when low and fast.
    if (p?.alive && !p.done && p.alt < 1.4 && !this.mapMode) {
      ctx.strokeStyle = `rgba(230,240,240,${0.12 * (1.4 - p.alt)})`;
      ctx.lineWidth = 1;
      const len = 30 + p.spd * 40;
      for (let i = 0; i < 14; i++) {
        const x = Math.random() * W;
        const y = Math.random() * H;
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(x - Math.cos(p.hdg) * len, y - Math.sin(p.hdg) * len);
        ctx.stroke();
      }
    }

    // Night over a burning city turns orange.
    const orange = clamp(this.fireGlow / 220) * (0.2 + night * 0.65);
    if (orange > 0.01) {
      ctx.globalCompositeOperation = 'lighter';
      ctx.fillStyle = `rgba(255,90,20,${orange * 0.32})`;
      ctx.fillRect(0, 0, W, H);
      ctx.globalCompositeOperation = 'source-over';
    }
    this.drawHud(X, Y, W, H);
    this.drawScreenFx(W, H);
  }

  private planeSize(q: Plane, z: number): number {
    const base = (GLYPH_SIZE[q.kind] ?? 0.4) * 2.3;
    return Math.max(q.isPlayer ? 34 : 20, base * z) * (1 + q.alt * 0.025) * (this.mapMode ? 0.8 : 1);
  }

  /**
   * Draw a sprite rotated to a heading. In a bank the wingspan narrows a little
   * (up to ~35° of roll) and, if `shade` is given, the dipping wing darkens while
   * the raised one brightens — so left and right turns look different.
   */
  private drawRot(img: HTMLCanvasElement, x: number, y: number, a: number, size: number, bank = 0, shade?: (side: 1 | -1) => HTMLCanvasElement): void {
    const ctx = this.ctx;
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(a);
    const roll = Math.max(-1, Math.min(1, bank)) * 0.6; // radians, ~35° at full rate
    if (roll) ctx.scale(1, Math.cos(roll));
    ctx.drawImage(img, -size / 2, -size / 2, size, size);
    if (shade && Math.abs(roll) > 0.02) {
      ctx.globalAlpha *= Math.min(1, Math.abs(roll) / 0.6);
      ctx.drawImage(shade(roll > 0 ? 1 : -1), -size / 2, -size / 2, size, size);
    }
    ctx.restore();
  }

  private drawGroundDefenses(X: (x: number) => number, Y: (y: number, a?: number) => number, z: number, W: number, H: number, night: number): void {
    const ctx = this.ctx;
    const t = this.w.t;
    ctx.font = '600 10px ui-monospace, monospace';
    ctx.textBaseline = 'middle';
    // Early-warning pickets.
    for (const pk of this.w.pickets) {
      const x = X(pk.x);
      const y = Y(pk.y);
      if (x < -60 || y < -60 || x > W + 60 || y > H + 60) continue;
      ctx.strokeStyle = pk.alive ? '#ff6a5a' : '#555';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(x - 6, y + 7);
      ctx.lineTo(x, y - 9);
      ctx.lineTo(x + 6, y + 7);
      ctx.stroke();
      if (pk.alive) {
        const a = t * 2;
        ctx.beginPath();
        ctx.moveTo(x - Math.cos(a) * 9, y - 9 - Math.sin(a) * 3);
        ctx.lineTo(x + Math.cos(a) * 9, y - 9 + Math.sin(a) * 3);
        ctx.stroke();
        ctx.fillStyle = 'rgba(255,150,130,0.9)';
        ctx.fillText('RADAR', x + 12, y - 4);
      } else {
        ctx.fillStyle = 'rgba(180,180,180,0.8)';
        ctx.fillText('✕', x + 12, y - 4);
      }
    }
    // SAM batteries.
    const art = samSite();
    for (const s of this.w.sites) {
      const st = s.st;
      const x = X(st.x);
      const y = Y(st.y);
      const R = CONFIG.sam[st.kind].range * z;
      if (x < -R - 20 || y < -R - 20 || x > W + R + 20 || y > H + R + 20) continue;
      const size = Math.max(26, 1.4 * z);
      const dead = s.state === 'dead';
      ctx.globalAlpha = dead ? 0.45 : 1;
      this.drawRot(art, x, y, (st.x * 7) % TAU, size);
      ctx.globalAlpha = 1;
      if (dead) {
        ctx.strokeStyle = 'rgba(30,20,10,0.9)';
        ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.moveTo(x - size / 3, y - size / 3); ctx.lineTo(x + size / 3, y + size / 3);
        ctx.moveTo(x + size / 3, y - size / 3); ctx.lineTo(x - size / 3, y + size / 3);
        ctx.stroke();
      } else if (s.emitting) {
        const a = t * (s.state === 'search' ? 2 : 6);
        ctx.strokeStyle = '#ff4a3a';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(x + Math.cos(a) * size * 0.25, y + Math.sin(a) * size * 0.25);
        ctx.stroke();
        const r = ((((t * 0.8 + st.x) % 1) + 1) % 1);
        ctx.strokeStyle = `rgba(255,70,60,${0.6 * (1 - r)})`;
        ctx.beginPath();
        ctx.arc(x, y, size * 0.5 + r * 30, 0, TAU);
        ctx.stroke();
      }
      if (!dead && (s.emitting || this.mapMode)) {
        ctx.setLineDash([6, 8]);
        ctx.strokeStyle = s.targetId === this.w.player?.id && (s.state === 'track' || s.state === 'guide') ? 'rgba(255,60,50,0.55)' : 'rgba(255,70,60,0.22)';
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        ctx.arc(x, y, R, 0, TAU);
        ctx.stroke();
        ctx.setLineDash([]);
      }
      const onMe = s.targetId === this.w.player?.id && (s.state === 'track' || s.state === 'guide');
      const state = dead ? '✕' : st.revealed && st.isDecoy ? 'DECOY' : !s.emitting ? '' : s.state === 'guide' ? 'LAUNCH' : onMe ? 'LOCK' : '';
      ctx.fillStyle = dead ? 'rgba(170,170,170,0.8)' : state ? '#ff5a4a' : 'rgba(255,170,150,0.92)';
      ctx.fillText(`SAM${state ? ` ${state}` : ''}`, x + size / 2 + 4, y);
    }
    // Gun pits with barrels when close enough to see them.
    if (z > 22 && !this.mapMode) {
      for (const gun of this.w.guns) {
        const x = X(gun.x);
        const y = Y(gun.y);
        if (x < -10 || y < -10 || x > W + 10 || y > H + 10) continue;
        ctx.fillStyle = gun.alive ? '#3c3a2c' : '#1b1610';
        ctx.beginPath();
        ctx.arc(x, y, gun.kind === 'heavy' ? 4 : 3, 0, TAU);
        ctx.fill();
        if (!gun.alive) continue;
        const tgt = gun.targetId >= 0 ? this.w.planes.find((q) => q.id === gun.targetId) : null;
        const a = tgt ? Math.atan2(tgt.y - gun.y, tgt.x - gun.x) : (gun.x * 3) % TAU;
        ctx.strokeStyle = '#151510';
        ctx.lineWidth = gun.kind === 'heavy' ? 2 : 1.5;
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(x + Math.cos(a) * 7, y + Math.sin(a) * 7);
        ctx.stroke();
        if (tgt && night > 0.4 && Math.random() < 0.2) {
          ctx.globalCompositeOperation = 'lighter';
          ctx.drawImage(glow(255, 200, 120), x - 6, y - 6, 12, 12);
          ctx.globalCompositeOperation = 'source-over';
        }
      }
    }
  }

  private drawClouds(X: (x: number) => number, Y: (y: number, a?: number) => number, z: number, night: number): void {
    const ctx = this.ctx;
    const sprites = clouds();
    const p = this.w.player;
    const above = (p?.alt ?? 0) > 6.5;
    ctx.globalAlpha = (above ? 0.75 : 0.42) * (1 - night * 0.6);
    for (const c of this.cloudList) {
      const w = c.s * z * 1.6;
      const h = w * 0.62;
      const x = X(c.x);
      const y = Y(c.y, c.alt);
      if (x + w < 0 || y + h < 0 || x - w > this.vw || y - h > this.vh) continue;
      ctx.drawImage(sprites[c.k], x - w / 2, y - h / 2, w, h);
    }
    ctx.globalAlpha = 1;
  }

  private drawPlanes(X: (x: number) => number, Y: (y: number, a?: number) => number, z: number, which: (q: Plane) => boolean, night: number): void {
    const ctx = this.ctx;
    const blink = Math.floor(performance.now() / 500) % 2 === 0;
    ctx.font = '600 10px ui-monospace, monospace';
    ctx.textBaseline = 'middle';
    for (const q of this.w.planes) {
      if (!q.alive || q.done || q.state === 'wait' || !which(q)) continue;
      const x = X(q.x);
      const y = Y(q.y, q.alt);
      const size = this.planeSize(q, z);
      if (x < -size || y < -size || x > this.vw + size || y > this.vh + size) continue;
      const red = q.side === 'red';
      const spec = red ? CONFIG.aircraft[q.kind as keyof typeof CONFIG.aircraft] : null;
      if ((!red || spec?.fighter) && q.spd > q.baseSpd * 1.02) {
        ctx.globalCompositeOperation = 'lighter';
        const tx = x - Math.cos(q.hdg) * size * 0.48;
        const ty = y - Math.sin(q.hdg) * size * 0.48;
        ctx.drawImage(glow(255, 150, 60), tx - size * 0.18, ty - size * 0.18, size * 0.36, size * 0.36);
        ctx.globalCompositeOperation = 'source-over';
      }
      this.drawRot(planeSprite(q.kind, q.isPlayer), x, y, q.hdg, size, q.bank, (side) => planeBankShade(q.kind, q.isPlayer, side));
      if (q.isPlayer && this.w.crew.doors > 0) {
        // Open bomb bay: a dark slot along the belly.
        ctx.save();
        ctx.translate(x, y);
        ctx.rotate(q.hdg);
        ctx.fillStyle = `rgba(10,10,10,${0.85 * this.w.crew.doors})`;
        ctx.fillRect(-size * 0.18, -size * 0.035 * this.w.crew.doors, size * 0.3, size * 0.07 * this.w.crew.doors);
        ctx.restore();
      }
      if (q.isPlayer && q.engineFire > 0) {
        ctx.globalCompositeOperation = 'lighter';
        const fx2 = x - Math.cos(q.hdg) * size * 0.1 + Math.cos(q.hdg + Math.PI / 2) * size * 0.25;
        const fy2 = y - Math.sin(q.hdg) * size * 0.1 + Math.sin(q.hdg + Math.PI / 2) * size * 0.25;
        const fr2 = size * (0.2 + Math.random() * 0.15);
        ctx.drawImage(glow(255, 120, 30), fx2 - fr2, fy2 - fr2, fr2 * 2, fr2 * 2);
        ctx.globalCompositeOperation = 'source-over';
      }
      if (!red) {
        ctx.strokeStyle = 'rgba(255,80,60,0.9)';
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.arc(x, y, size * 0.55, 0, TAU);
        ctx.stroke();
      }
      if (night > 0.3 && q.kind !== 'drone') {
        const c = Math.cos(q.hdg + Math.PI / 2);
        const s2 = Math.sin(q.hdg + Math.PI / 2);
        ctx.globalCompositeOperation = 'lighter';
        if (blink || q.isPlayer) {
          ctx.drawImage(glow(255, 40, 30), x - c * size * 0.42 - 3, y - s2 * size * 0.42 - 3, 6, 6);
          ctx.drawImage(glow(40, 255, 80), x + c * size * 0.42 - 3, y + s2 * size * 0.42 - 3, 6, 6);
        }
        ctx.globalCompositeOperation = 'source-over';
      }
      if (q.searchlit > 0) {
        ctx.globalCompositeOperation = 'lighter';
        ctx.globalAlpha = 0.5;
        ctx.drawImage(glow(225, 235, 255), x - size, y - size, size * 2, size * 2);
        ctx.globalAlpha = 1;
        ctx.globalCompositeOperation = 'source-over';
      }
      if (q.isPlayer) {
        ctx.strokeStyle = '#bff4ff';
        ctx.lineWidth = 1.5;
        const b = size * 0.62;
        for (const [ax, ay] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
          ctx.beginPath();
          ctx.moveTo(x + ax * b, y + ay * b * 0.6);
          ctx.lineTo(x + ax * b, y + ay * b);
          ctx.lineTo(x + ax * b * 0.6, y + ay * b);
          ctx.stroke();
        }
        ctx.fillStyle = '#e9fbff';
        ctx.fillText('YOU', x + size * 0.68, y - size * 0.45);
      } else if (!red) {
        ctx.fillStyle = '#ff8a7a';
        ctx.fillText(String(q.kind), x + size * 0.6, y - 6);
      } else if (q.kind === 'drone') {
        if (z > 8) {
          ctx.fillStyle = 'rgba(230,220,180,0.75)';
          ctx.fillText('drone', x + size * 0.6, y - 6);
        }
      } else if (q.slot === 0 || this.mapMode || z > 90) {
        ctx.fillStyle = 'rgba(170,230,255,0.85)';
        ctx.fillText(q.callsign, x + size * 0.6, y - 6);
      }
    }
  }

  private drawMissiles(X: (x: number) => number, Y: (y: number, a?: number) => number, z: number, which: (alt: number) => boolean): void {
    const ctx = this.ctx;
    const p = this.w.player;
    for (const m of this.w.missiles) {
      if (!which(m.alt)) continue;
      const x = X(m.x);
      const y = Y(m.y, m.alt);
      const L = Math.max(9, 0.35 * z);
      ctx.strokeStyle = '#f2f2ea';
      ctx.lineWidth = 2.5;
      ctx.beginPath();
      ctx.moveTo(x, y);
      ctx.lineTo(x - Math.cos(m.hdg) * L, y - Math.sin(m.hdg) * L);
      ctx.stroke();
      ctx.globalCompositeOperation = 'lighter';
      const fx = x - Math.cos(m.hdg) * L;
      const fy = y - Math.sin(m.hdg) * L;
      const boost = (m.kind === 'nike' && m.age < 3.4) || (m.kind === 'bloodhound' && m.age < 2.6);
      const r = (boost ? 12 : 6) + Math.random() * (boost ? 8 : 4);
      ctx.drawImage(glow(255, 170, 70), fx - r, fy - r, r * 2, r * 2);
      ctx.globalCompositeOperation = 'source-over';
      if (p && m.targetId === p.id && m.side === 'blue') {
        ctx.strokeStyle = Math.floor(performance.now() / 120) % 2 ? '#ff3b30' : '#ffffff';
        ctx.lineWidth = 1.5;
        ctx.strokeRect(x - 12, y - 12, 24, 24);
        ctx.fillStyle = '#ff5a4a';
        ctx.font = '700 11px ui-monospace, monospace';
        ctx.fillText(`MISSILE ${Math.hypot(m.x - p.x, m.y - p.y).toFixed(1)} km`, x + 15, y - 10);
      }
    }
  }

  private drawParticles(X: (x: number) => number, Y: (y: number, a?: number) => number, z: number, glowPass: boolean): void {
    const ctx = this.ctx;
    const night = 1 - this.w.light;
    ctx.globalCompositeOperation = glowPass ? 'lighter' : 'source-over';
    const smokeDark = puff(40, 38, 36, 1);
    const smokeNight = puff(70, 40, 25, 2);
    const flakPuff = puff(14, 14, 14, 3);
    const trailPuff = puff(220, 220, 215, 0);
    const dustPuff = puff(150, 135, 100, 2);
    const hydPuff = puff(140, 100, 50, 1);
    const fireG = glow(255, 120, 40);
    const flashG = glow(255, 240, 210, 0.5);
    const sparkG = glow(255, 210, 140);
    const chaffG = glow(210, 225, 255, 0.6);
    const colors = ['#ffd27a', '#ff7a4a', '#a0ffc8', '#ffb050', '#ff5a3a', '#7dff8a'];
    for (const p of this.fx.items) {
      if (!p.on) continue;
      const k = p.kind;
      const glowy = k === 'flash' || k === 'fire' || k === 'fireball' || k === 'spark' || k === 'ember' || k === 'tracer' || k === 'chaff' || k === 'ring' || k === 'shock';
      if (glowy !== glowPass) continue;
      const f = p.life / p.max;
      const x = X(p.x);
      const y = Y(p.y, p.alt);
      const r = Math.max(1, p.size * z);
      if (x < -r * 3 - 40 || y < -r * 3 - 40 || x > this.vw + r * 3 + 40 || y > this.vh + r * 3 + 40) continue;
      switch (k) {
        case 'column':
          ctx.globalAlpha = 0.65 * Math.min(1, f * 1.5);
          ctx.drawImage(dustPuff, x - r * 1.5, y - r * 1.5, r * 3, r * 3);
          break;
        case 'booster':
          ctx.globalAlpha = 1;
          ctx.strokeStyle = '#d8d4c4';
          ctx.lineWidth = 3;
          ctx.beginPath();
          ctx.moveTo(x - Math.cos(p.life * 7) * 6, y - Math.sin(p.life * 7) * 6);
          ctx.lineTo(x + Math.cos(p.life * 7) * 6, y + Math.sin(p.life * 7) * 6);
          ctx.stroke();
          break;
        case 'shock':
          ctx.globalAlpha = f * 0.85;
          ctx.strokeStyle = '#f4f8ff';
          ctx.lineWidth = 2.5;
          ctx.beginPath();
          ctx.arc(x, y, r, 0, TAU);
          ctx.stroke();
          break;
        case 'smoke':
        case 'dust':
        case 'trail':
        case 'contrail': {
          const img = k === 'dust' ? dustPuff : k === 'trail' ? (p.hue === 2 ? hydPuff : trailPuff) : k === 'contrail' ? trailPuff : p.hue && night > 0.5 ? smokeNight : smokeDark;
          ctx.globalAlpha = (k === 'contrail' ? 0.3 : k === 'trail' ? 0.55 : 0.6) * Math.min(1, f * 1.6);
          ctx.drawImage(img, x - r * 1.4, y - r * 1.4, r * 2.8, r * 2.8);
          break;
        }
        case 'flak':
          ctx.globalAlpha = 0.85 * Math.min(1, f * 2);
          ctx.drawImage(flakPuff, x - r * 1.5, y - r * 1.5, r * 3, r * 3);
          break;
        case 'debris':
          ctx.globalAlpha = f;
          ctx.fillStyle = '#2a201a';
          ctx.fillRect(x - 1.5, y - 1.5, 3, 3);
          break;
        case 'chute':
          ctx.globalAlpha = Math.min(1, f * 3);
          ctx.strokeStyle = '#f0efe6';
          ctx.fillStyle = 'rgba(240,235,220,0.8)';
          ctx.beginPath();
          ctx.arc(x, y - 5, 5, Math.PI, 0);
          ctx.fill();
          ctx.beginPath();
          ctx.moveTo(x - 5, y - 5);
          ctx.lineTo(x, y + 3);
          ctx.lineTo(x + 5, y - 5);
          ctx.stroke();
          break;
        case 'flash':
          ctx.globalAlpha = f;
          ctx.drawImage(flashG, x - r, y - r, r * 2, r * 2);
          break;
        case 'fireball':
          ctx.globalAlpha = Math.min(1, f * 1.4);
          ctx.drawImage(fireG, x - r, y - r, r * 2, r * 2);
          ctx.globalAlpha = f * 0.6;
          ctx.drawImage(flashG, x - r * 0.5, y - r * 0.5, r, r);
          break;
        case 'fire':
          ctx.globalAlpha = 0.7 * f;
          ctx.drawImage(fireG, x - r * 1.3, y - r * 1.3, r * 2.6, r * 2.6);
          break;
        case 'ember':
        case 'spark':
          ctx.globalAlpha = f;
          ctx.strokeStyle = k === 'ember' ? '#ff9a40' : '#ffe0a0';
          ctx.lineWidth = 1.4;
          ctx.beginPath();
          ctx.moveTo(x, y);
          ctx.lineTo(x - p.vx * z * 0.07, y - p.vy * z * 0.07);
          ctx.stroke();
          ctx.drawImage(sparkG, x - 2, y - 2, 4, 4);
          break;
        case 'tracer': {
          ctx.globalAlpha = f;
          ctx.strokeStyle = colors[p.hue] ?? '#ffd27a';
          ctx.lineWidth = p.hue >= 3 ? 2.6 : 2;
          const hx = X(p.x2);
          const hy = Y(p.y2, p.alt);
          ctx.beginPath();
          ctx.moveTo(x, y);
          ctx.lineTo(hx, hy);
          ctx.stroke();
          if (p.hue >= 3 || night > 0.5) ctx.drawImage(p.hue === 5 || p.hue === 2 ? glow(120, 255, 140) : glow(255, 150, 60), hx - 4, hy - 4, 8, 8);
          break;
        }
        case 'chaff':
          ctx.globalAlpha = f * (Math.random() < 0.3 ? 1 : 0.4);
          ctx.drawImage(chaffG, x - 2, y - 2, 4, 4);
          break;
        case 'ring':
          ctx.globalAlpha = f * 0.7;
          ctx.strokeStyle = '#ffe6c8';
          ctx.lineWidth = 2;
          ctx.beginPath();
          ctx.arc(x, y, r, 0, TAU);
          ctx.stroke();
          break;
      }
    }
    ctx.globalAlpha = 1;
    ctx.globalCompositeOperation = 'source-over';
  }

  // ── HUD: pictures first, words last ──
  private drawHud(X: (x: number) => number, Y: (y: number, a?: number) => number, W: number, H: number): void {
    const ctx = this.ctx;
    const p = this.w.player;
    ctx.textBaseline = 'top';
    const compact = W < 560;
    const coarse = this.coarse;
    if (p && p.alive && !p.done) {
      const fr = p.flight!;
      // Target diamond (or an arrow at the edge pointing to it).
      const tx = X(fr.aimX);
      const ty = Y(fr.aimY);
      const pulse = 10 + Math.sin(performance.now() / 180) * 2;
      ctx.strokeStyle = '#ffd36a';
      ctx.lineWidth = 2.5;
      if (tx > 0 && ty > 0 && tx < W && ty < H) {
        ctx.beginPath();
        ctx.moveTo(tx, ty - pulse); ctx.lineTo(tx + pulse, ty); ctx.lineTo(tx, ty + pulse); ctx.lineTo(tx - pulse, ty); ctx.closePath();
        ctx.stroke();
      } else {
        this.edgeArrow(tx, ty, '#ffd36a', `◆ ${Math.round(Math.hypot(fr.aimX - p.x, fr.aimY - p.y))} km`, W, H);
      }
      this.drawSight(X, Y, p);
      for (const m of this.w.missiles) {
        if (m.side !== 'blue' || m.targetId !== p.id) continue;
        const mx = X(m.x);
        const my = Y(m.y, m.alt);
        if (mx < 0 || my < 0 || mx > W || my > H) this.edgeArrow(mx, my, '#ff3b30', '▲', W, H);
      }
      for (const q of this.w.planes) {
        if (q.side !== 'blue' || !q.alive || q.done || Math.hypot(q.x - p.x, q.y - p.y) > 25) continue;
        const qx = X(q.x);
        const qy = Y(q.y, q.alt);
        if (qx < 0 || qy < 0 || qx > W || qy > H) this.edgeArrow(qx, qy, '#ff8a6a', '✈', W, H);
      }
      if (this.w.guidedBomb?.alive) {
        const b = this.w.guidedBomb;
        const gx = X(b.steerX);
        const gy = Y(b.steerY);
        ctx.strokeStyle = 'rgba(140,255,170,0.95)';
        ctx.lineWidth = 1.5;
        ctx.strokeRect(gx - 10, gy - 10, 20, 20);
        ctx.beginPath();
        ctx.moveTo(gx - 16, gy); ctx.lineTo(gx + 16, gy); ctx.moveTo(gx, gy - 16); ctx.lineTo(gx, gy + 16);
        ctx.stroke();
        this.drawTV(W, H, b.steerX, b.steerY, b.ft - b.t);
      }
      // Beam advice: a curved arrow around your aircraft.
      if (this.w.crew.beamTurn !== 0 && this.rwrCache.launchClose > 0) {
        const px = X(p.x);
        const py = Y(p.y, p.alt);
        const dir = this.w.crew.beamTurn;
        const r = 54;
        const a0 = p.hdg - dir * 0.6;
        const a1 = p.hdg + dir * 0.9;
        ctx.strokeStyle = Math.floor(performance.now() / 200) % 2 ? '#ff5a4a' : '#ffd0c8';
        ctx.lineWidth = 4;
        ctx.beginPath();
        ctx.arc(px, py, r, Math.min(a0, a1), Math.max(a0, a1));
        ctx.stroke();
        const ex = px + Math.cos(a1) * r;
        const ey = py + Math.sin(a1) * r;
        const tang = a1 + (dir * Math.PI) / 2;
        ctx.fillStyle = ctx.strokeStyle;
        ctx.beginPath();
        ctx.moveTo(ex + Math.cos(tang) * 12, ey + Math.sin(tang) * 12);
        ctx.lineTo(ex + Math.cos(tang + 2.5) * 10, ey + Math.sin(tang + 2.5) * 10);
        ctx.lineTo(ex + Math.cos(tang - 2.5) * 10, ey + Math.sin(tang - 2.5) * 10);
        ctx.closePath();
        ctx.fill();
      }
    }
    // Objective pill: target, distance, which way.
    if (p) this.drawObjective(W, compact ? 142 : 50, p);
    // Cockpit cluster (scaled down on phones).
    if (p) {
      ctx.save();
      const k = compact ? 0.72 : 1;
      ctx.translate(10, 10);
      ctx.scale(k, k);
      this.drawCluster(p);
      ctx.restore();
    }
    // Radar warning receiver.
    const r = this.rwrCache;
    const R = compact ? 40 : 56;
    const rx = W - R - 14;
    const ry = 62 + R + (compact ? 22 : 30);
    ctx.fillStyle = 'rgba(2,8,10,0.82)';
    ctx.beginPath();
    ctx.arc(rx, ry, R + 5, 0, TAU);
    ctx.fill();
    ctx.strokeStyle = r.locked ? 'rgba(255,70,60,0.95)' : 'rgba(95,211,255,0.55)';
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.arc(rx, ry, R, 0, TAU);
    ctx.arc(rx, ry, R * 0.5, 0, TAU);
    ctx.stroke();
    // Your aircraft at the center of the scope.
    ctx.fillStyle = 'rgba(170,240,255,0.8)';
    ctx.beginPath();
    ctx.moveTo(rx, ry - 6); ctx.lineTo(rx + 5, ry + 5); ctx.lineTo(rx, ry + 2); ctx.lineTo(rx - 5, ry + 5); ctx.closePath();
    ctx.fill();
    if (p) {
      ctx.textBaseline = 'middle';
      for (const t of r.threats) {
        const rel = t.b - p.hdg - Math.PI / 2;
        const rr = R * clamp(0.25 + (1 - t.d / 60) * 0.75, 0.2, 0.95);
        const tx = rx + Math.cos(rel) * rr;
        const ty = ry + Math.sin(rel) * rr;
        if (t.launch && Math.sin(performance.now() / 70) < 0) continue;
        ctx.fillStyle = t.launch ? '#ff3b30' : t.lock ? '#ffb347' : t.kind === 'A' ? '#ffa060' : '#9fe8ff';
        ctx.font = t.lock ? '700 12px ui-monospace, monospace' : '10px ui-monospace, monospace';
        ctx.fillText(t.kind, tx - 4, ty);
        if (t.lock) {
          ctx.strokeStyle = ctx.fillStyle;
          ctx.strokeRect(tx - 7, ty - 8, 14, 16);
        }
      }
      ctx.textBaseline = 'top';
      if (r.launchClose > 0 || r.locked) {
        ctx.fillStyle = r.launchClose > 0 ? (Math.sin(performance.now() / 60) > 0 ? '#ff3b30' : '#ffffff') : '#ffb347';
        ctx.font = '700 13px ui-monospace, monospace';
        const label = r.launchClose > 0 ? 'MISSILE' : 'LOCK';
        ctx.fillText(label, rx - ctx.measureText(label).width / 2, ry - R - 20);
      }
    }
    if (!compact && !coarse && !this.mapMode && H > 480) this.drawMinimap(W, H, p);
    // Radio: only the latest couple of calls, fading fast.
    ctx.font = compact ? '10px ui-monospace, monospace' : '11px ui-monospace, monospace';
    const now = performance.now();
    const lineH = compact ? 13 : 15;
    const lx = coarse && W > H ? 164 : 10;
    const base = coarse ? (W > H ? H - 66 : H - 210) : H - (compact ? 60 : 64);
    const shown = this.radioLog.slice(-3).filter((l) => now - l.t < 7000);
    shown.forEach((l, i) => {
      const a = clamp(1.5 - (now - l.t) / 4700);
      const text = `${l.from}: ${l.text}`;
      const maxChars = Math.floor((coarse ? (W > H ? W - 340 : W - 175) : W - (compact ? 20 : 230)) / (compact ? 6.2 : 6.8));
      const shownText = text.length > maxChars ? text.slice(0, maxChars - 1) + '…' : text;
      ctx.fillStyle = `rgba(0,0,0,${0.5 * a})`;
      ctx.fillRect(lx - 3, base + i * lineH - 2, ctx.measureText(shownText).width + 6, lineH);
      ctx.fillStyle = l.urgent ? `rgba(255,130,110,${a})` : `rgba(190,238,255,${a})`;
      ctx.fillText(shownText, lx, base + i * lineH);
    });
    // One instruction at a time: a key cap and a word or two.
    const hint = this.hint();
    if (hint) {
      ctx.font = `800 ${compact ? 13 : 17}px ui-monospace, monospace`;
      const keysW = hint.keys.reduce((a, k) => a + Math.max(24, k.length * 8 + 12) + 6, 0);
      const tw = Math.min(W - 16, ctx.measureText(hint.text).width + keysW + 28);
      const hx = (W - tw) / 2;
      const hy = coarse ? (W > H ? H - 120 : H - 262) : H - 132;
      const flashOn = !hint.urgent || Math.floor(performance.now() / 250) % 2 === 0;
      ctx.fillStyle = hint.urgent ? 'rgba(90,0,0,0.82)' : 'rgba(3,10,12,0.78)';
      ctx.beginPath();
      ctx.roundRect(hx, hy, tw, 34, 6);
      ctx.fill();
      ctx.strokeStyle = hint.urgent ? '#ff4a3a' : 'rgba(140,255,170,0.7)';
      ctx.lineWidth = 1.5;
      ctx.stroke();
      let kx = hx + 10;
      for (const k of hint.keys) kx += keycap(ctx, kx, hy + 6, k, hint.urgent) + 6;
      ctx.font = `800 ${compact ? 13 : 17}px ui-monospace, monospace`;
      ctx.textBaseline = 'middle';
      ctx.fillStyle = flashOn ? (hint.urgent ? '#ffe2dc' : '#c8ffd4') : 'rgba(255,255,255,0.45)';
      ctx.fillText(hint.text, kx + 4, hy + 17);
      ctx.textBaseline = 'top';
    }
    if (!p?.alive && this.spectate) {
      ctx.fillStyle = 'rgba(180,235,255,0.75)';
      ctx.font = '10px ui-monospace, monospace';
      ctx.fillText(`WATCHING ${this.spectate.callsign}`, lx, base - 16);
    }
  }

  /** CCIP sight: a straight bomb-fall line on the ground from your shadow to the pipper. */
  private drawSight(X: (x: number) => number, Y: (y: number, a?: number) => number, p: Plane): void {
    const fr = p.flight!;
    const bombs = p.sticks > 0 && p.munition && !CONFIG.munitions[p.munition].missile && p.munition !== 'S-24' && Math.hypot(fr.aimX - p.x, fr.aimY - p.y) < 32;
    if (!bombs) return;
    const ctx = this.ctx;
    const c = ccip(this.w, p);
    // Both ends on the ground plane, so the line runs straight along the track.
    const sx = X(p.x);
    const sy = Y(p.y);
    const px = X(c.x);
    const py = Y(c.y);
    const ang = Math.atan2(py - sy, px - sx);
    const onTarget = Math.hypot(c.x - fr.aimX, c.y - fr.aimY) < 0.9;
    const col = onTarget ? '#8cffaa' : 'rgba(140,255,170,0.85)';
    ctx.strokeStyle = col;
    ctx.lineWidth = onTarget ? 2.5 : 1.5;
    ctx.setLineDash([6, 5]);
    ctx.beginPath();
    ctx.moveTo(sx, sy);
    ctx.lineTo(px - Math.cos(ang) * 12, py - Math.sin(ang) * 12);
    ctx.stroke();
    ctx.setLineDash([]);
    // Reticle aligned with the bomb-fall line.
    ctx.save();
    ctx.translate(px, py);
    ctx.rotate(ang);
    const rr = onTarget ? 13 : 10;
    ctx.beginPath();
    ctx.arc(0, 0, rr, 0, TAU);
    ctx.moveTo(-rr - 8, 0); ctx.lineTo(-rr + 3, 0);
    ctx.moveTo(rr - 3, 0); ctx.lineTo(rr + 8, 0);
    ctx.moveTo(0, -rr - 6); ctx.lineTo(0, -rr + 3);
    ctx.moveTo(0, rr - 3); ctx.lineTo(0, rr + 6);
    ctx.stroke();
    ctx.fillStyle = col;
    ctx.beginPath();
    ctx.arc(0, 0, 2, 0, TAU);
    ctx.fill();
    ctx.restore();
    // A tick where your shadow is, so you can see where the line starts.
    ctx.beginPath();
    ctx.arc(sx, sy, 3, 0, TAU);
    ctx.fill();
  }

  private drawObjective(W: number, y: number, p: Plane): void {
    const ctx = this.ctx;
    const fr = p.flight!;
    const d = Math.hypot(fr.aimX - p.x, fr.aimY - p.y);
    const egress = p.done || (p.sticks === 0 && !!p.munition) || (fr.released && p.role === 'strike');
    const tname = !p.alive ? 'SHOT DOWN' : egress ? 'HOME →' : (SHORT_NAMES[fr.aimKey ?? ''] ?? DISTRICT_BY_ID[fr.aimKey ?? '']?.name ?? (fr.aimKey === 'pickets' ? 'RADAR' : 'TARGET')).toUpperCase();
    const rel = egress ? wrap(0 - p.hdg) : wrap(Math.atan2(fr.aimY - p.y, fr.aimX - p.x) - p.hdg);
    ctx.font = '700 13px ui-monospace, monospace';
    const text = !p.alive || egress ? tname : `${tname}  ${Math.round(d)} km`;
    const tw = ctx.measureText(text).width + 70;
    const ox = (W - tw) / 2;
    ctx.fillStyle = 'rgba(3,10,12,0.8)';
    ctx.beginPath();
    ctx.roundRect(ox, y, tw, 28, 14);
    ctx.fill();
    ctx.strokeStyle = egress ? 'rgba(125,255,160,0.7)' : 'rgba(255,211,106,0.7)';
    ctx.lineWidth = 1.5;
    ctx.stroke();
    // Diamond icon.
    const color = egress ? '#7dffa0' : d < 30 ? '#ff9a6a' : '#ffd36a';
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.moveTo(ox + 16, y + 6); ctx.lineTo(ox + 24, y + 14); ctx.lineTo(ox + 16, y + 22); ctx.lineTo(ox + 8, y + 14); ctx.closePath();
    ctx.fill();
    ctx.fillStyle = '#eef8fa';
    ctx.textBaseline = 'middle';
    ctx.fillText(text, ox + 32, y + 15);
    // Steering arrow: rotates to point where the target is relative to your nose.
    if (p.alive) {
      const ax = ox + tw - 20;
      const ay = y + 14;
      ctx.save();
      ctx.translate(ax, ay);
      ctx.rotate(rel - Math.PI / 2); // straight ahead points up
      ctx.fillStyle = Math.abs(rel) < 0.15 ? '#7dffa0' : color;
      ctx.beginPath();
      ctx.moveTo(9, 0); ctx.lineTo(-6, -7); ctx.lineTo(-2, 0); ctx.lineTo(-6, 7); ctx.closePath();
      ctx.fill();
      ctx.restore();
    }
    ctx.textBaseline = 'top';
  }

  private silhouette: HTMLCanvasElement | null = null;
  /** The cockpit cluster: damage diagram, altitude, fuel, stores, doors, warning lights. */
  private drawCluster(p: Plane): void {
    const ctx = this.ctx;
    const cr = this.w.crew;
    const K = CONFIG.mission.crew;
    const blink = Math.floor(performance.now() / 300) % 2 === 0;
    const flick = p.instruments < 0.7 && Math.random() > p.instruments ? 0.25 : 1;
    const Wd = 214;
    const Hd = 168;
    ctx.fillStyle = 'rgba(2,8,10,0.8)';
    ctx.beginPath();
    ctx.roundRect(0, 0, Wd, Hd, 8);
    ctx.fill();
    ctx.strokeStyle = 'rgba(95,211,255,0.4)';
    ctx.lineWidth = 1;
    ctx.stroke();
    ctx.globalAlpha = flick;
    // Damage diagram: your own silhouette, colored by airframe health.
    const dmg = 1 - p.hp / p.maxHp;
    const col = !p.alive ? '#555' : dmg > 0.6 ? '#ff4a3a' : dmg > 0.3 ? '#ffb347' : '#5fd3a0';
    if (!this.silhouette) this.silhouette = document.createElement('canvas');
    const sil = this.silhouette;
    sil.width = sil.height = 128;
    const sctx = sil.getContext('2d')!;
    sctx.clearRect(0, 0, 128, 128);
    sctx.save();
    sctx.translate(64, 64);
    sctx.rotate(-Math.PI / 2);
    sctx.drawImage(planeShadow(p.kind), -64, -64, 128, 128);
    sctx.restore();
    sctx.globalCompositeOperation = 'source-in';
    sctx.fillStyle = col;
    sctx.fillRect(0, 0, 128, 128);
    ctx.drawImage(sil, 4, 4, 92, 92);
    if (p.engineFire > 0 && blink) flame(ctx, 70, 54, 11);
    if (p.hydraulics > 0.15) drop(ctx, 50, 66, 7, '#5fa8ff');
    if (cr.fuelLeak > 0 && !cr.leakIsolated) drop(ctx, 30, 54, 7, blink ? '#ffd36a' : '#a07a20');
    // Altitude tape (height above ground) with the danger zone at the bottom.
    const alt = cr.agl;
    gauge(ctx, 104, 8, 14, 88, Math.min(1, alt / 12), cr.pullUp ? '#ff3b30' : '#9fe8ff', K.pullUpAglKm / 12 + 0.02);
    ctx.fillStyle = '#e8f8fc';
    ctx.font = '800 18px ui-monospace, monospace';
    ctx.fillText(alt >= 1 ? `${alt.toFixed(1)}` : `${Math.round(alt * 1000)}`, 124, 10);
    ctx.font = '10px ui-monospace, monospace';
    ctx.fillStyle = 'rgba(170,240,255,0.7)';
    ctx.fillText(alt >= 1 ? 'km up' : 'm up', 124, 30);
    ctx.fillStyle = '#e8f8fc';
    ctx.font = '700 14px ui-monospace, monospace';
    ctx.fillText(`${Math.round(p.spd * 1000)}`, 124, 48);
    ctx.font = '10px ui-monospace, monospace';
    ctx.fillStyle = 'rgba(170,240,255,0.7)';
    ctx.fillText('km/h', 124, 64);
    // Fuel gauge.
    gauge(ctx, 190, 8, 14, 88, cr.fuel, cr.fuel < K.bingo ? '#ff5a4a' : '#ffd36a', K.bingo);
    fuelCan(ctx, 197, 106, 8, cr.fuel < K.bingo ? '#ff5a4a' : 'rgba(255,211,106,0.8)');
    // Stores: one bomb per stick left; chaff as small bars.
    const sticks = p.munition ? p.sticks : p.role === 'escort' ? p.missiles : 0;
    for (let i = 0; i < Math.min(10, sticks); i++) bomb(ctx, 10 + i * 9, 112, 11, p.munition && CONFIG.munitions[p.munition].missile ? '#ffb347' : '#d8d0b8');
    if (!sticks && (p.munition || p.role === 'escort')) {
      ctx.fillStyle = 'rgba(170,240,255,0.5)';
      ctx.fillText('EMPTY', 10, 106);
    }
    ctx.fillStyle = '#c8d4ff';
    for (let i = 0; i < Math.min(12, p.chaff); i++) ctx.fillRect(110 + i * 5, 106, 3, 12);
    // Bomb doors, release mode, formation.
    if (p.munition && !CONFIG.munitions[p.munition].missile) {
      bayDoors(ctx, 10, 126, 46, cr.doors);
      releaseMode(ctx, 64, 126, cr.mode);
    }
    formation(ctx, 104, 128, cr.formation);
    ctx.globalAlpha = 1;
    // Warning lights: lit pictures, each with its key.
    const lights: [string, boolean, (x: number, y: number) => void][] = [
      ['E', p.engineFire > 0, (x, y) => flame(ctx, x, y, 7)],
      ['H', p.hydraulics > 0.15 && cr.hydBackup, (x, y) => drop(ctx, x, y, 6, '#5fa8ff')],
      ['K', cr.fuelLeak > 0 && !cr.leakIsolated, (x, y) => fuelCan(ctx, x, y, 7, '#ffd36a')],
      ['B', cr.hung > 0, (x, y) => bomb(ctx, x, y, 11, '#ff9a6a')],
    ];
    lights.forEach(([key, on, icon], i) => {
      const x = 140 + (i % 2) * 36;
      const y = 124 + Math.floor(i / 2) * 22;
      ctx.fillStyle = on ? (blink ? 'rgba(200,40,20,0.95)' : 'rgba(110,20,10,0.95)') : 'rgba(30,40,44,0.7)';
      ctx.beginPath();
      ctx.roundRect(x, y, 32, 19, 3);
      ctx.fill();
      ctx.globalAlpha = on ? 1 : 0.3;
      icon(x + 9, y + 10);
      ctx.fillStyle = '#fff';
      ctx.font = '700 10px ui-monospace, monospace';
      ctx.fillText(key, x + 20, y + 4);
      ctx.globalAlpha = 1;
    });
  }

  private hint(): { keys: string[]; text: string; urgent: boolean } | null {
    const p = this.w.player;
    if (!p || !p.alive || p.done) return null;
    const r = this.rwrCache;
    const cr = this.w.crew;
    const H = (keys: string[], text: string, urgent = true) => ({ keys, text, urgent });
    if (cr.pullUp) return H(['↑'], 'PULL UP!');
    if (r.launchClose > 0.2) return H([cr.beamTurn > 0 ? '→' : cr.beamTurn < 0 ? '←' : '↔', ...(p.chaff > 0 ? ['C'] : [])], 'MISSILE — BEAM IT');
    if (p.engineFire > 0) return H(cr.extinguishers > 0 ? ['E'] : ['↓'], cr.extinguishers > 0 ? 'ENGINE FIRE' : 'DIVE — NO BOTTLES');
    if (cr.hung > 0) return H(['B', 'B'], 'HUNG BOMBS');
    if (cr.tailTarget >= 0) return H(['G'], 'FIGHTER BEHIND');
    if (cr.fuelLeak > 0 && !cr.leakIsolated) return H(['K'], 'FUEL LEAK');
    if (p.hydraulics > 0.15 && cr.hydBackup) return H(['H'], 'HYDRAULICS', false);
    if (cr.flamedOut) return H([], 'ENGINES OUT — GLIDE EAST');
    if (cr.fuel < CONFIG.mission.crew.bingo) return H([], 'BINGO FUEL — GO HOME');
    if (p.searchlit > 0) return H(['↓'], 'SEARCHLIGHTS — DIVE');
    const fr = p.flight!;
    if (p.sticks > 0 && p.munition && !CONFIG.munitions[p.munition].missile && p.munition !== 'S-24') {
      const c = ccip(this.w, p);
      const d = Math.hypot(c.x - fr.aimX, c.y - fr.aimY);
      const far = Math.hypot(fr.aimX - p.x, fr.aimY - p.y);
      if (cr.doors < 1 && far < 22) return cr.doorsWant ? H([], 'DOORS OPENING…', false) : H(['B'], 'OPEN DOORS', far < 10);
      if (d < 0.9) return H(['SPACE'], cr.mode === 'salvo' ? 'SALVO!' : 'DROP!');
      if (d < 4) return H([], '◎ ONTO ◆', false);
    }
    if (p.role === 'weasel' && p.sticks > 0 && this.w.sites.some((s) => s.emitting && Math.hypot(s.st.x - p.x, s.st.y - p.y) < CONFIG.mission.armRange && Math.abs(wrap(Math.atan2(s.st.y - p.y, s.st.x - p.x) - p.hdg)) < CONFIG.mission.armCone)) {
      return H(['SPACE'], 'MAGNUM!');
    }
    if (p.munition && CONFIG.munitions[p.munition].missile && p.munition !== 'KSR-11' && p.sticks > 0) {
      const d = Math.hypot(fr.aimX - p.x, fr.aimY - p.y);
      if (d < CONFIG.mission.standoffRange && d > CONFIG.mission.standoffMin) return H(['SPACE'], 'LAUNCH');
    }
    if (this.w.t - this.flakNearT < 2.5) return H(['←', '→'], 'FLAK — JINK', false);
    if (r.locked) return H([], 'SAM LOCK', false);
    return null;
  }

  private edgeArrow(tx: number, ty: number, color: string, label: string, W: number, H: number): void {
    const ctx = this.ctx;
    const a = Math.atan2(ty - H / 2, tx - W / 2);
    const mx = W / 2 - 34;
    const my = H / 2 - 40;
    const k = Math.min(mx / Math.abs(Math.cos(a) || 1e-6), my / Math.abs(Math.sin(a) || 1e-6));
    const ax = W / 2 + Math.cos(a) * k;
    const ay = H / 2 + Math.sin(a) * k;
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.moveTo(ax + Math.cos(a) * 13, ay + Math.sin(a) * 13);
    ctx.lineTo(ax + Math.cos(a + 2.4) * 11, ay + Math.sin(a + 2.4) * 11);
    ctx.lineTo(ax + Math.cos(a - 2.4) * 11, ay + Math.sin(a - 2.4) * 11);
    ctx.closePath();
    ctx.fill();
    ctx.font = '700 10px ui-monospace, monospace';
    ctx.textBaseline = 'middle';
    const w = ctx.measureText(label).width;
    const lx = clamp(ax - Math.cos(a) * 24 - w / 2, 4, W - w - 4);
    const ly = clamp(ay - Math.sin(a) * 20, 10, H - 10);
    ctx.fillStyle = 'rgba(0,0,0,0.6)';
    ctx.fillRect(lx - 3, ly - 7, w + 6, 14);
    ctx.fillStyle = color;
    ctx.fillText(label, lx, ly);
    ctx.textBaseline = 'top';
  }

  private drawMinimap(W: number, H: number, p: Plane | null): void {
    const ctx = this.ctx;
    const S = 150;
    const x0 = W - S - 10;
    const y0 = H - S - 34;
    const span = 90;
    const k = S / span;
    const cxw = p?.x ?? this.camX;
    const cyw = p?.y ?? this.camY;
    const MX = (x: number) => x0 + S / 2 + (x - cxw) * k;
    const MY = (y: number) => y0 + S / 2 + (y - cyw) * k;
    ctx.save();
    ctx.fillStyle = 'rgba(2,8,10,0.85)';
    ctx.fillRect(x0, y0, S, S);
    ctx.beginPath();
    ctx.rect(x0, y0, S, S);
    ctx.clip();
    const L = this.layers;
    ctx.globalAlpha = 0.55;
    ctx.drawImage(L.world, (cxw - span / 2 - L.worldBox.x0) * L.worldBox.s, (cyw - span / 2 - L.worldBox.y0) * L.worldBox.s, span * L.worldBox.s, span * L.worldBox.s, x0, y0, S, S);
    ctx.globalAlpha = 1;
    ctx.strokeStyle = 'rgba(255,80,60,0.5)';
    ctx.setLineDash([3, 3]);
    ctx.beginPath();
    ctx.moveTo(MX(BARRIER_X), y0);
    ctx.lineTo(MX(BARRIER_X), y0 + S);
    ctx.stroke();
    ctx.setLineDash([]);
    for (const s of this.w.sites) {
      if (s.state === 'dead') continue;
      ctx.strokeStyle = s.emitting ? 'rgba(255,70,60,0.75)' : 'rgba(255,70,60,0.3)';
      ctx.beginPath();
      ctx.arc(MX(s.st.x), MY(s.st.y), CONFIG.sam[s.st.kind].range * k, 0, TAU);
      ctx.stroke();
    }
    for (const q of this.w.planes) {
      if (!q.alive || q.done || q.state === 'wait') continue;
      ctx.fillStyle = q.isPlayer ? '#ffffff' : q.side === 'red' ? (q.kind === 'drone' ? '#d8cfa0' : '#5fd3ff') : '#ff5a4a';
      const s2 = q.isPlayer ? 5 : 3;
      ctx.fillRect(MX(q.x) - s2 / 2, MY(q.y) - s2 / 2, s2, s2);
    }
    for (const m of this.w.missiles) {
      ctx.fillStyle = m.side === 'blue' ? '#ffffff' : '#9ff';
      ctx.fillRect(MX(m.x) - 1, MY(m.y) - 1, 2, 2);
    }
    if (p) {
      const fr = p.flight!;
      ctx.strokeStyle = '#ffd36a';
      const tx = MX(fr.aimX);
      const ty = MY(fr.aimY);
      ctx.beginPath();
      ctx.moveTo(tx, ty - 5); ctx.lineTo(tx + 5, ty); ctx.lineTo(tx, ty + 5); ctx.lineTo(tx - 5, ty); ctx.closePath();
      ctx.stroke();
    }
    ctx.restore();
    ctx.strokeStyle = 'rgba(95,211,255,0.45)';
    ctx.strokeRect(x0 + 0.5, y0 + 0.5, S, S);
    ctx.fillStyle = 'rgba(95,211,255,0.85)';
    ctx.font = '10px ui-monospace, monospace';
    ctx.fillText('THEATER · 90 km', x0, y0 - 13);
  }

  /** Grainy white-hot guidance video for the UPAB, like the 1991 strike tapes. */
  private drawTV(W: number, H: number, x: number, y: number, tLeft: number): void {
    const ctx = this.ctx;
    const s = Math.min(170, W * 0.32);
    const x0 = W - s - 12;
    const y0 = H - s - (W < 560 ? 170 : 205);
    const L = this.layers;
    ctx.save();
    ctx.fillStyle = '#000';
    ctx.fillRect(x0, y0, s, s);
    ctx.beginPath();
    ctx.rect(x0, y0, s, s);
    ctx.clip();
    const span = 3.2;
    ctx.filter = 'grayscale(1) contrast(1.6) brightness(1.05)';
    const sx = (x - span / 2 - L.cityBox.x0) * L.cityBox.s;
    const sy = (y - span / 2 - L.cityBox.y0) * L.cityBox.s;
    ctx.drawImage(L.city, sx, sy, span * L.cityBox.s, span * L.cityBox.s, x0, y0, s, s);
    ctx.drawImage(L.scars, sx, sy, span * L.cityBox.s, span * L.cityBox.s, x0, y0, s, s);
    ctx.filter = 'none';
    for (let i = 0; i < 160; i++) {
      ctx.fillStyle = `rgba(255,255,255,${Math.random() * 0.12})`;
      ctx.fillRect(x0 + Math.random() * s, y0 + Math.random() * s, 2, 1);
    }
    ctx.fillStyle = 'rgba(0,0,0,0.12)';
    for (let yy = y0; yy < y0 + s; yy += 3) ctx.fillRect(x0, yy, s, 1);
    ctx.strokeStyle = 'rgba(255,255,255,0.9)';
    ctx.lineWidth = 1.5;
    const c = s / 2;
    ctx.beginPath();
    ctx.moveTo(x0 + c - 22, y0 + c); ctx.lineTo(x0 + c - 6, y0 + c);
    ctx.moveTo(x0 + c + 6, y0 + c); ctx.lineTo(x0 + c + 22, y0 + c);
    ctx.moveTo(x0 + c, y0 + c - 22); ctx.lineTo(x0 + c, y0 + c - 6);
    ctx.moveTo(x0 + c, y0 + c + 6); ctx.lineTo(x0 + c, y0 + c + 22);
    ctx.stroke();
    ctx.strokeRect(x0 + c - 30, y0 + c - 30, 60, 60);
    ctx.fillStyle = '#fff';
    ctx.font = '10px ui-monospace, monospace';
    ctx.fillText(`TTI ${Math.max(0, tLeft * CONFIG.mission.timeCompression).toFixed(1)}s`, x0 + 6, y0 + 6);
    ctx.fillText('WHT HOT', x0 + s - 54, y0 + 6);
    ctx.restore();
    ctx.fillStyle = 'rgba(200,255,210,0.9)';
    ctx.font = '700 10px ui-monospace, monospace';
    ctx.fillText('UPAB TV — STEER WITH ARROWS', x0, y0 - 14);
  }

  private drawScreenFx(W: number, H: number): void {
    const ctx = this.ctx;
    const p = this.w?.player;
    const motion = Math.max(0.25, prefs.motion);
    if (p?.searchlit) this.sfx.glare = Math.max(this.sfx.glare, 0.6);
    if (this.sfx.glare > 0) {
      ctx.fillStyle = `rgba(235,240,255,${this.sfx.glare * 0.55 * motion})`;
      ctx.fillRect(0, 0, W, H);
    }
    if (this.sfx.flash > 0) {
      ctx.fillStyle = `rgba(255,255,255,${this.sfx.flash * 0.6 * motion})`;
      ctx.fillRect(0, 0, W, H);
    }
    // Soft lens vignette for depth; red pulse when hurt.
    const g0 = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.75);
    g0.addColorStop(0, 'rgba(0,0,0,0)');
    g0.addColorStop(1, 'rgba(0,0,0,0.45)');
    ctx.fillStyle = g0;
    ctx.fillRect(0, 0, W, H);
    const dmg = p?.alive ? 1 - p.hp / p.maxHp : 0;
    const vig = Math.max(this.sfx.vignette, dmg > 0.6 ? 0.3 + 0.2 * Math.sin(performance.now() / 300) : 0);
    if (vig > 0.01) {
      const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.25, W / 2, H / 2, Math.max(W, H) * 0.7);
      g.addColorStop(0, 'rgba(160,0,0,0)');
      g.addColorStop(1, `rgba(170,0,0,${vig * 0.75})`);
      ctx.fillStyle = g;
      ctx.fillRect(0, 0, W, H);
    }
    if (this.sfx.dirt > 0) {
      ctx.fillStyle = `rgba(40,30,20,${this.sfx.dirt * 0.7})`;
      for (let i = 0; i < 40 * this.sfx.dirt; i++) {
        const x = (((i * 9301 + 49297) % 233280) / 233280) * W;
        const y = (((i * 7919 + 1543) % 104729) / 104729) * H;
        ctx.beginPath();
        ctx.arc(x, y, 1 + (i % 4), 0, TAU);
        ctx.fill();
      }
    }
  }

  // ── Battle-damage landing ──
  private landingStep(dt: number): void {
    if (this.paused) return;
    const L = this.landing;
    const p = this.w.player!;
    L.t += dt;
    L.gust += (Math.random() - 0.5) * dt * 2;
    L.gust *= 0.98;
    const k = this.keys;
    let input = (k.has('arrowright') || k.has('d') ? 1 : 0) - (k.has('arrowleft') || k.has('a') ? 1 : 0);
    if (this.touch.stickId >= 0) input = this.touch.dx;
    L.vx += (input * 0.9 + p.controlBias * 1.4 + L.gust - L.vx * 0.6) * dt;
    L.off += L.vx * dt;
    L.alt = Math.max(0, 1 - L.t / 11);
    const dmg = 1 - p.hp / p.maxHp;
    if (Math.random() < dt * dmg * 2) this.sfx.kick(4);
    this.sfx.update(dt);
    if (L.alt <= 0 && !L.result) {
      const off = Math.abs(L.off);
      const drift = Math.abs(L.vx);
      if (off < 0.25 && drift < 0.35) L.result = 'ok';
      else if (off < 0.6 || dmg < 0.7) L.result = 'crashlanded';
      else L.result = Math.random() < 0.5 ? 'crashlanded' : 'killed';
      this.sfx.kick(L.result === 'ok' ? 6 : 20);
      audio.boom(0.1, L.result === 'ok' ? 0.4 : 1);
      const outcome = this.result!.outcomes.find((o) => o.airframeId === p.airframeId);
      if (outcome && L.result !== 'ok') outcome.fate = L.result;
      if (L.result !== 'ok') this.result!.playerFate = L.result;
      const msg = L.result === 'ok' ? `Down and rolling. The ground crew counts the holes: ${Math.round(dmg * 300)}.` : L.result === 'crashlanded' ? 'Gear collapses — she slides off the runway in a shower of sparks. The crew climbs out. The airframe is finished.' : 'The aircraft cartwheels at the runway threshold. No one gets out.';
      this.banner.innerHTML = `<div class="brief"><div class="brief-t">${L.result === 'ok' ? 'Survived the trip' : L.result === 'crashlanded' ? 'Crash landing' : 'Lost on landing'}</div><p>${msg}</p><button type="button" class="primary" data-act="next" data-tip="Go to the bomb damage assessment.">Debrief</button></div>`;
      this.banner.hidden = false;
      this.banner.querySelector<HTMLButtonElement>('[data-act=next]')!.onclick = () => this.complete();
    }
  }

  private drawLanding(): void {
    const ctx = this.ctx;
    const W = this.vw;
    const H = this.vh;
    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
    const [sx, sy] = this.sfx.offset();
    ctx.fillStyle = '#3b4330';
    ctx.fillRect(0, 0, W, H);
    const L = this.landing;
    const p = this.w.player!;
    const scale = 1 + (1 - L.alt) * 2.5;
    const cx = W / 2 + sx;
    const rwW = 60 * scale;
    ctx.fillStyle = '#6d6c66';
    ctx.fillRect(cx - rwW / 2, -10, rwW, H + 20);
    ctx.strokeStyle = 'rgba(240,240,230,0.9)';
    ctx.lineWidth = 2;
    ctx.setLineDash([16, 20]);
    ctx.beginPath();
    ctx.moveTo(cx, -10 + ((L.t * 120 * scale) % 36));
    ctx.lineTo(cx, H + 10);
    ctx.stroke();
    ctx.setLineDash([]);
    const px = cx + L.off * 120 * scale;
    const py = H * 0.62 + sy;
    const size = 90;
    ctx.globalAlpha = 0.4;
    this.drawRot(planeShadow(p.kind), px + 10, py + 10 + L.alt * 30, -Math.PI / 2 + L.vx * 0.5, size);
    ctx.globalAlpha = 1;
    this.drawRot(planeSprite(p.kind, true), px, py - L.alt * 40, -Math.PI / 2 + L.vx * 0.5, size);
    if (p.engineFire > 0 || 1 - p.hp / p.maxHp > 0.6) {
      ctx.globalAlpha = 0.6;
      ctx.drawImage(puff(40, 38, 36, 1), px - 30, py - L.alt * 40 + 20, 60, 60);
      ctx.globalAlpha = 1;
    }
    ctx.fillStyle = 'rgba(160,240,255,0.95)';
    ctx.font = '12px ui-monospace, monospace';
    ctx.textBaseline = 'top';
    ctx.fillText(`ALT ${Math.round(L.alt * 300)} m   DRIFT ${(L.off * 30).toFixed(0)} m`, 12, 14);
    ctx.fillText('Hold the dashed centerline: ←/→ or stick', 12, 30);
    this.drawScreenFx(W, H);
  }
}