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