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