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