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/aftermath.ts
// Debrief: bomb damage assessment photos, losses, the enemy's newspaper.
import { audio } from '../game/audio';
import { CONFIG } from '../game/config';
import { abandonCaptive, attemptRescue, type AftermathReport, type BdaEntry } from '../game/campaign';
import { DISTRICT_BY_ID } from '../game/map';
import { fmtTime, type GameState, type NewsItem } from '../game/state';
import { bind, esc, modal, pct } from './dom';
import { paintDistrict, paintGround, paintNetworks, paintWater } from '../art/terrain';
import { samSite } from '../art/sprites';

export interface AftermathHost {
  s: GameState;
  done(): void;
  persist(): void;
}

export class AftermathView {
  constructor(private root: HTMLElement, private host: AftermathHost, private r: AftermathReport, private fresh: NewsItem[]) {
    this.root.replaceChildren();
    this.root.className = 'screen aftermath';
    this.render();
    audio.teletype();
  }

  destroy(): void {
    /* static */
  }

  private render(): void {
    const s = this.host.s;
    const r = this.r;
    const lost = r.lost.map((l) => `<li><b>${esc(l.airframe.tail)}</b> ${esc(l.airframe.type)} — ${esc(l.airframe.crew)}: <em>${l.fate === 'killed' ? 'killed in action' : l.fate === 'captured' ? 'bailed out over enemy territory' : l.fate === 'crashlanded' ? 'written off on landing; crew survived' : 'crew bailed out over friendly lines'}</em></li>`).join('');
    const enemy = [r.messages[0], ...this.fresh.filter((n) => n.kind === 'enemy' || n.kind === 'moral').map((n) => n.text)].filter(Boolean).slice(0, 6);
    const hq = this.fresh.filter((n) => n.kind === 'hq' || n.kind === 'intel').map((n) => n.text).slice(0, 4);
    const pending = s.captives.map((c, i) => ({ c, i })).filter((q) => q.c.resolved === 'pending');
    const pf = r.playerFate;
    const youLine = pf === 'ok' ? 'You brought your crew home.' : pf === 'killed' ? 'You did not come back. Command passes to your deputy for the rest of the campaign.' : pf === 'captured' ? 'You were shot down over enemy territory and taken prisoner. Your deputy takes the regiment.' : pf === 'crashlanded' ? 'You crash-landed. Your crew walked away from the wreck.' : pf === 'ejectedHome' ? 'You bailed out over friendly lines and were picked up.' : 'You commanded from the bunker.';
    this.root.innerHTML = `<div class="after">
      <header><span class="k">Bomb damage assessment</span><b>${fmtTime(r.hour)}</b></header>
      <div class="after-cols">
        <section class="bda">
          <h3>Strike photos</h3>
          <div class="photos">${r.bda.length ? r.bda.map((b, i) => `<figure><canvas width="200" height="150" data-photo="${i}" aria-label="Strike photo of ${esc(b.name)}"></canvas><figcaption><b>${esc(b.decoy ? b.name.replace('?', '') : b.name)}</b><br>${(b.kg / 1000).toFixed(1)} t · ${b.decoy ? '<span class="bad">PLYWOOD DECOY — no rubble mass, no secondary fires</span>' : b.kind === 'sam' ? (b.integrityAfter < 0.25 ? 'launchers destroyed' : 'launchers damaged') : `integrity ${pct(b.integrityAfter)}`}</figcaption></figure>`).join('') : '<p class="note">No bombs found a target. The photos show empty fields and craters.</p>'}</div>
        </section>
        <section class="numbers">
          <h3>Report</h3>
          <ul class="kv">
            <li><span>War output</span><b>${pct(r.outputBefore)} → ${pct(r.outputAfter)}</b></li>
            <li><span>Tonnage dropped</span><b>${r.tonnage.toFixed(1)} t</b></li>
            <li><span>On decoys</span><b class="${r.decoyTonnage > 0 ? 'bad' : ''}">${r.decoyTonnage.toFixed(1)} t</b></li>
            <li><span>Civilians killed</span><b class="${r.civilians > 300 ? 'bad' : ''}">${r.civilians.toLocaleString('en')}</b></li>
            <li><span>Aircraft lost</span><b class="${r.lost.length ? 'bad' : ''}">${r.lost.length}</b></li>
            <li><span>Aircraft damaged</span><b>${r.damaged}</b></li>
            <li><span>Political capital</span><b>${Math.round(r.politicalBefore)} → ${Math.round(r.politicalAfter)}</b></li>
          </ul>
          <p>${esc(youLine)}</p>
          ${r.roeViolations.length ? `<p class="warn">Restrictions ignored: ${r.roeViolations.map(esc).join('; ')}</p>` : ''}
          ${lost ? `<h3>Missing</h3><ul class="lost">${lost}</ul>` : ''}
          ${pending.length ? `<h3>Crews down behind the lines</h3>${pending.map(({ c, i }) => `<div class="captive"><b>${esc(c.crew)}</b> (${esc(c.type)})<div class="row"><button type="button" data-act="rescue" data-i="${i}" data-tip="A helicopter goes in low to pick the crew up. Roughly even odds; the helicopter itself may be lost. Takes 6 hours.">Launch Mi-8 rescue — costs 6 h</button><button type="button" data-act="leave" data-i="${i}" data-tip="They will be taken prisoner. The enemy will parade them on television.">Leave them</button></div></div>`).join('')}` : ''}
          ${hq.length ? `<h3>Regiment</h3><ul class="news">${hq.map((t) => `<li>${esc(t)}</li>`).join('')}</ul>` : ''}
        </section>
        <section class="paper">
          <div class="masthead">HAFENSTÄDTER ABENDBLATT</div>
          <div class="paper-date">${fmtTime(r.hour)} · Price 20 Pf.</div>
          ${enemy.map((t, i) => (i === 0 ? `<h2>${esc(t)}</h2>` : `<p>${esc(t)}</p>`)).join('')}
        </section>
      </div>
      <footer><button type="button" class="primary big" data-act="done" data-tip="Back to the command post. Time moves on; the city starts repairing.">Return to HQ</button></footer>
    </div>`;
    bind(this.root, {
      done: () => this.host.done(),
      rescue: (t) => {
        const txt = attemptRescue(this.host.s, Number(t.dataset.i));
        this.host.persist();
        modal(this.root, `<h2>Rescue</h2><p>${esc(txt)}</p><button type="button" class="primary" data-act="close">Continue</button>`, {});
        this.render();
      },
      leave: (t) => { abandonCaptive(this.host.s, Number(t.dataset.i)); this.render(); },
    });
    r.bda.forEach((b, i) => {
      const c = this.root.querySelector<HTMLCanvasElement>(`[data-photo="${i}"]`);
      if (c) drawPhoto(c, b, this.host.s);
    });
  }
}

/** Grainy reconnaissance print: the real target, craters, smoke, analyst marks. */
function drawPhoto(c: HTMLCanvasElement, b: BdaEntry, s: GameState): void {
  const ctx = c.getContext('2d')!;
  const W = c.width;
  const H = c.height;
  const z = 26;
  const id = b.key.split(':')[1];
  const d = DISTRICT_BY_ID[id];
  ctx.save();
  ctx.setTransform(z, 0, 0, z, W / 2 - b.cx * z, H / 2 - b.cy * z);
  paintGround(ctx, b.cx - W / z, b.cy - H / z, b.cx + W / z, b.cy + H / z, 7, 2.5);
  paintWater(ctx);
  paintNetworks(ctx, 1968);
  if (d) paintDistrict(ctx, d, b.cx, b.cy, 1968, b.decoy ? 0.8 : 1);
  ctx.restore();
  if (!d) {
    const art = samSite();
    ctx.drawImage(art, W / 2 - 40, H / 2 - 40, 80, 80);
  }
  // Rubble where it really is destroyed.
  if (d && !b.decoy) {
    ctx.fillStyle = `rgba(30,22,16,${(1 - b.integrityAfter) * 0.5})`;
    ctx.fillRect(W / 2 - (d.w * z) / 2, H / 2 - (d.h * z) / 2, d.w * z, d.h * z);
  }
  // Craters.
  for (const h of b.hits) {
    const x = W / 2 + (h.x - b.cx) * z;
    const y = H / 2 + (h.y - b.cy) * z;
    const g = ctx.createRadialGradient(x, y, 0, x, y, 9);
    g.addColorStop(0, 'rgba(10,8,6,0.95)');
    g.addColorStop(0.6, 'rgba(40,30,20,0.5)');
    g.addColorStop(1, 'rgba(40,30,20,0)');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(x, y, 9, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = 'rgba(220,210,190,0.35)';
    ctx.beginPath();
    ctx.arc(x, y, 5 + Math.random() * 2, 0, Math.PI * 2);
    ctx.stroke();
  }
  // Smoke where it really burns.
  const fire = d && !b.decoy ? s.districts[d.id].fire : 0;
  for (let i = 0; i < fire * 20; i++) {
    ctx.fillStyle = `rgba(230,230,225,${0.08 + Math.random() * 0.1})`;
    ctx.beginPath();
    ctx.arc(W / 2 + (Math.random() - 0.3) * 80, H / 2 + (Math.random() - 0.6) * 60, 8 + Math.random() * 18, 0, Math.PI * 2);
    ctx.fill();
  }
  // Silver-halide grain.
  const img = ctx.getImageData(0, 0, W, H);
  for (let i = 0; i < img.data.length; i += 4) {
    const n = (Math.random() - 0.5) * 34;
    const g = Math.max(0, Math.min(255, 0.3 * img.data[i] + 0.59 * img.data[i + 1] + 0.11 * img.data[i + 2] + 10 + n));
    img.data[i] = g;
    img.data[i + 1] = g;
    img.data[i + 2] = g * 0.95;
  }
  ctx.putImageData(img, 0, 0);
  // Analyst grease pencil.
  ctx.strokeStyle = b.decoy ? '#ff5544' : '#f2e27a';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(W / 2, H / 2, 52, 0, Math.PI * 2);
  ctx.stroke();
  ctx.fillStyle = b.decoy ? '#ff5544' : '#f2e27a';
  ctx.font = 'bold 13px ui-monospace, monospace';
  ctx.fillText(b.decoy ? 'DUMMY — PLYWOOD' : b.kind === 'sam' ? 'SAM SITE' : `${Math.round((b.integrityAfter ?? 0) * 100)}% LEFT`, 6, 16);
  ctx.font = '10px ui-monospace, monospace';
  ctx.fillText(`FRAME ${Math.floor(1000 + Math.random() * 8999)}  ${CONFIG.campaign.days}D`, 6, H - 6);
}