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.
// Screen flow: title → HQ ⇄ planner → mission → debrief → … → end report.
import { audio } from '../game/audio';
import { warOutput } from '../game/city';
import { applyMission, advanceTo, consumeForMission, planProblems, type MissionPlan, type MissionResult } from '../game/campaign';
import { canSave, clearSave, loadGame, saveGame } from '../game/save';
import { autoResolve, createWorld, finishWorld } from '../game/sim';
import { fmtTime, newCampaign, type GameState } from '../game/state';
import { AftermathView } from './aftermath';
import { bind, esc, modal } from './dom';
import { EndView } from './end';
import { HqView } from './hq';
import { MissionView } from './mission';
import { PlannerView } from './planner';
import { TitleScene } from './titlescene';
interface View { destroy(): void }
export class App {
s!: GameState;
private view: View | null = null;
readonly canSave = canSave();
private saved: GameState | null = null;
constructor(private root: HTMLElement) {
void this.boot();
}
private async boot(): Promise<void> {
this.titleScreen(true);
this.saved = await loadGame();
this.titleScreen(false);
}
private swap(v: View | null): void {
this.view?.destroy();
this.view = v;
}
title(): void {
this.persist();
this.saved = this.s ?? this.saved;
this.titleScreen(false);
}
private titleScreen(loading: boolean): void {
this.swap(null);
const scene = new TitleScene();
this.view = scene;
this.root.className = 'screen title';
const cont = this.saved && !this.saved.ended;
this.root.innerHTML = `<div class="title-in">
<div class="title-k">Дальняя авиация · 1968</div>
<div class="title-t" aria-hidden="true">CITY<br>BREAKER</div>
<p class="title-p">You command a Soviet long-range aviation regiment. Hafenstadt — 1.2 million people, steel, rail, a river port and a power plant — must stop producing for war before the summer offensive. You have 60 days. The city will fight to live.</p>
<div class="col">
${loading ? '<p class="note">Checking the regiment\'s files…</p>' : ''}
${cont ? `<button type="button" class="primary big" data-act="continue" data-tip="Resume your saved campaign where you left off.">Continue — ${esc(fmtTime(this.saved!.hour))}</button>` : ''}
<button type="button" class="${cont ? '' : 'primary big'}" data-act="new" data-tip="Start a fresh 60-day campaign against Hafenstadt.${cont ? ' This replaces your saved one.' : ''}">New campaign</button>
<button type="button" data-act="how" data-tip="A short guide to the strategy layer and the controls.">How it works</button>
</div>
<p class="note">${this.canSave ? 'Campaigns save to this napplet\'s shell storage.' : 'No shell storage in this runtime — the campaign cannot be resumed after closing.'} The horror is in the numbers.</p>
</div>`;
this.root.prepend(scene.canvas);
bind(this.root, {
continue: () => { audio.start(); this.s = this.saved!; this.hq(); },
new: () => { audio.start(); this.newCampaign(); },
how: () => this.how(),
});
}
private how(): void {
modal(this.root, `<h2>How it works</h2>
<p><b>Strategy.</b> Time passes in 6-hour turns. You decide when to launch and at what hour of day or night. Every hour you wait, the city repairs, disperses, builds decoys and adapts to your last five raids.</p>
<p><b>War output</b> = industry × logistics × power × workforce, with bottlenecks. Win by driving it below 15% by day 60, or by holding power, rail yards, the river crossing and fuel down together for 5 days.</p>
<p><b>Political capital</b> drains with civilian deaths, restricted targets and lost aircraft. Low capital brings rules of engagement. You can declare an all-out offensive and ignore them — at a price.</p>
<p><b>Flying.</b> You pilot one aircraft per raid. Arrows/WASD steer and climb, Space fires or releases bombs, F is the secondary weapon, C drops chaff, T speeds up time, M toggles the map, R hands the raid to the AI. On touch: stick on the left, buttons on the right.</p>
<p><b>Your crew stations.</b> <b>B</b> bomb doors (must be open to drop) · <b>Q</b> release mode · <b>V</b> pathfinder marker · hold <b>G</b> to take the tail gun · <b>E</b> extinguisher · <b>H</b> backup hydraulics · <b>K</b> isolate a fuel leak · <b>1/2/3</b> formation. Watch fuel, the radar altimeter and the warning lights.</p>
<p><b>The RWR</b> is your survival instrument: soft ping = search radar, fast tone = SAM lock, rising scream = missile closing. Break across the missile's path, dive, pop chaff.</p>
<button type="button" class="primary" data-act="close">Understood</button>`, {});
}
newCampaign(): void {
void clearSave();
this.s = newCampaign((Date.now() & 0x7fffffff) | 1);
advanceTo(this.s, this.s.hour);
this.s.news.unshift({ hour: this.s.hour, kind: 'hq', text: 'ORDER No. 1: Neutralize the war capacity of Hafenstadt before the summer offensive. You have sixty days.' });
this.s.news.unshift({ hour: this.s.hour, kind: 'enemy', text: '"Hafenstadt is ready. Every citizen a defender, every roof a firewatch."' });
this.persist();
this.hq();
}
persist(): void {
if (this.s) void saveGame(this.s);
}
hq(): void {
if (this.s.ended) return this.end();
this.swap(new HqView(this.root, {
s: this.s, canSave: this.canSave,
plan: (t) => this.plan(t), persist: () => this.persist(), title: () => this.title(),
newCampaign: () => this.confirmNew(), end: () => this.end(),
}));
}
private confirmNew(): void {
modal(this.root, '<h2>Abandon the campaign?</h2><p>The current campaign will be erased.</p><div class="row"><button type="button" class="danger" data-act="yes">Abandon</button><button type="button" data-act="close">Keep fighting</button></div>', {
yes: (_t, close) => { close(); this.newCampaign(); },
});
}
plan(target?: string): void {
this.swap(new PlannerView(this.root, { s: this.s, launch: (p, auto) => this.launch(p, auto), back: () => this.hq() }, target));
}
private launch(plan: MissionPlan, auto: boolean): void {
const s = this.s;
// The city keeps working until the hour you chose.
advanceTo(s, plan.launchHour);
if (s.ended) return this.end();
const problems = planProblems(s, plan);
if (problems.length) {
this.hq();
modal(this.root, `<h2>Raid scrubbed</h2><ul class="news">${problems.map((p) => `<li>${esc(p)}</li>`).join('')}</ul><button type="button" class="primary" data-act="close">Back to HQ</button>`, {});
return;
}
consumeForMission(s, plan);
const outBefore = warOutput(s).output;
const polBefore = s.political;
const newsMark = s.news.length ? s.news[0] : null;
const finish = (r: MissionResult) => this.debrief(plan, r, outBefore, polBefore, newsMark);
if (auto) {
const w = createWorld(s, plan);
autoResolve(w);
finish(finishWorld(w));
return;
}
this.swap(new MissionView(this.root, s, plan, finish));
}
private debrief(plan: MissionPlan, r: MissionResult, outBefore: number, polBefore: number, newsMark: GameState['news'][number] | null): void {
const s = this.s;
const report = applyMission(s, plan, r, outBefore, polBefore);
const idx = newsMark ? s.news.indexOf(newsMark) : s.news.length;
const fresh = s.news.slice(0, idx < 0 ? 10 : idx);
this.persist();
this.swap(new AftermathView(this.root, { s, done: () => this.hq(), persist: () => this.persist() }, report, fresh));
}
end(): void {
this.persist();
this.swap(new EndView(this.root, this.s, () => this.newCampaign()));
}
}
