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.
// Title backdrop: a night raid drifting across Hafenstadt.
import { glow, planeShadow, planeSprite, puff } from '../art/sprites';
import { rng, terrainLayers } from '../art/terrain';
import { DISTRICTS } from '../game/map';
export class TitleScene {
readonly canvas = document.createElement('canvas');
private ctx = this.canvas.getContext('2d')!;
private raf = 0;
private t = 0;
private last = performance.now();
private flak: { x: number; y: number; life: number; size: number }[] = [];
private fires: { x: number; y: number; r: number }[] = [];
private lights: { x: number; y: number; b: number }[] = [];
private ready = false;
constructor() {
this.canvas.className = 'title-canvas';
this.canvas.setAttribute('aria-hidden', 'true');
const r = rng(42);
for (let i = 0; i < 14; i++) {
const d = DISTRICTS[Math.floor(r() * 18)];
this.fires.push({ x: d.x + (r() - 0.5) * d.w, y: d.y + (r() - 0.5) * d.h, r: 0.6 + r() * 1.4 });
}
for (let i = 0; i < 1400; i++) {
const a = r() * Math.PI * 2;
const d = Math.sqrt(r()) * 18;
this.lights.push({ x: Math.cos(a) * d * 1.15 - 2, y: Math.sin(a) * d * 0.85, b: 0.3 + r() * 0.7 });
}
// Paint the terrain after the title text has rendered.
setTimeout(() => {
terrainLayers();
this.ready = true;
}, 60);
this.raf = requestAnimationFrame((t) => this.frame(t));
}
destroy(): void {
cancelAnimationFrame(this.raf);
}
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;
this.t += dt;
const c = this.canvas;
const dpr = Math.min(2, window.devicePixelRatio || 1);
const W = c.clientWidth;
const H = c.clientHeight;
if (c.width !== Math.round(W * dpr) || c.height !== Math.round(H * dpr)) {
c.width = Math.round(W * dpr);
c.height = Math.round(H * dpr);
}
const ctx = this.ctx;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#05080c';
ctx.fillRect(0, 0, W, H);
if (!this.ready || !W || !H) return;
const z = Math.max(W, H) / 34;
const camX = -6 + Math.sin(this.t * 0.03) * 6 + this.t * 0.12 - 6;
const camY = -2 + Math.cos(this.t * 0.04) * 3;
const X = (x: number) => W / 2 + (x - camX) * z;
const Y = (y: number) => H / 2 + (y - camY) * z;
const L = terrainLayers();
ctx.save();
ctx.setTransform(dpr * z, 0, 0, dpr * z, dpr * (W / 2 - camX * z), dpr * (H / 2 - camY * z));
ctx.drawImage(L.city, L.cityBox.x0, L.cityBox.y0, L.city.width / L.cityBox.s, L.city.height / L.cityBox.s);
ctx.restore();
ctx.fillStyle = 'rgba(3,6,20,0.84)';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
const lamp = glow(255, 180, 90, 0.2);
for (const l of this.lights) {
ctx.globalAlpha = l.b * 0.7;
ctx.drawImage(lamp, X(l.x) - 3, Y(l.y) - 3, 6, 6);
}
const fire = glow(255, 110, 30, 0.25);
for (const f of this.fires) {
const r = f.r * z * (0.9 + Math.sin(this.t * 7 + f.x) * 0.1);
ctx.globalAlpha = 0.85;
ctx.drawImage(fire, X(f.x) - r, Y(f.y) - r, r * 2, r * 2);
}
// Searchlights.
for (let i = 0; i < 7; i++) {
const bx = -12 + i * 4;
const by = 2 + (i % 3) * 3;
const a = this.t * (0.2 + i * 0.05) + i;
const ex = bx + Math.cos(a) * 9;
const ey = by + Math.sin(a) * 6 - 4;
const g = ctx.createLinearGradient(X(bx), Y(by), X(ex), Y(ey));
g.addColorStop(0, 'rgba(200,220,255,0.22)');
g.addColorStop(1, 'rgba(200,220,255,0)');
ctx.fillStyle = g;
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.moveTo(X(bx), Y(by));
ctx.lineTo(X(ex) + 18, Y(ey));
ctx.lineTo(X(ex) - 18, Y(ey));
ctx.closePath();
ctx.fill();
}
// Tracers rising.
for (let i = 0; i < 16; i++) {
const k = (this.t * 0.6 + i * 0.37) % 1;
const bx = -14 + ((i * 7.3) % 26);
const by = -6 + ((i * 3.1) % 12);
const ang = -1.1 + (i % 5) * 0.12;
const x = bx + Math.cos(ang) * k * 6;
const y = by + Math.sin(ang) * k * 6;
ctx.globalAlpha = 1 - k;
ctx.strokeStyle = ['#ffd27a', '#ff6a3a', '#9dff9a'][i % 3];
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(X(x), Y(y));
ctx.lineTo(X(x + Math.cos(ang) * 0.5), Y(y + Math.sin(ang) * 0.5));
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
// Flak.
if (Math.random() < dt * 5) this.flak.push({ x: camX + (Math.random() - 0.5) * 30, y: camY + (Math.random() - 0.5) * 18, life: 6, size: 0.4 + Math.random() * 0.5 });
const fp = puff(14, 14, 14, 3);
for (const f of this.flak) {
f.life -= dt;
f.size += dt * 0.06;
ctx.globalAlpha = Math.max(0, Math.min(1, f.life / 3)) * 0.8;
const r = f.size * z;
ctx.drawImage(fp, X(f.x) - r, Y(f.y) - r, r * 2, r * 2);
if (f.life > 5.85) {
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = 1;
ctx.drawImage(glow(255, 220, 160), X(f.x) - r, Y(f.y) - r, r * 2, r * 2);
ctx.globalCompositeOperation = 'source-over';
}
}
this.flak = this.flak.filter((f) => f.life > 0);
// Bomber stream.
ctx.globalAlpha = 1;
for (let i = 0; i < 7; i++) {
const k = ((this.t * 0.035 + i * 0.13) % 1.3) - 0.15;
const x = camX + 22 - k * 50 + (i % 2) * 1.5;
const y = camY - 8 + k * 14 + i * 1.2;
const hdg = Math.atan2(14, -50);
const size = 3.2 * z * 0.38;
ctx.globalAlpha = 0.25;
this.rot(planeShadow('Tu-16'), X(x + 1.4), Y(y + 1.6), hdg, size);
ctx.globalAlpha = 1;
this.rot(planeSprite('Tu-16'), X(x), Y(y), hdg, size);
ctx.globalCompositeOperation = 'lighter';
if (Math.floor(this.t * 2 + i) % 2) {
ctx.drawImage(glow(255, 40, 30), X(x) - 3, Y(y) - 3 - size * 0.4, 6, 6);
}
ctx.globalCompositeOperation = 'source-over';
}
// Vignette.
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.3, W / 2, H / 2, Math.max(W, H) * 0.75);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.75)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
private rot(img: HTMLCanvasElement, x: number, y: number, a: number, size: number): void {
const ctx = this.ctx;
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.drawImage(img, -size / 2, -size / 2, size, size);
ctx.restore();
}
}
