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/game/fx.ts
// Pooled particles and screen-space violence. Simple shapes, additive light,
// density over detail. World-space particles; the renderer supplies a camera.
import { CONFIG } from './config';
import { prefs } from './prefs';

export type PKind =
  | 'smoke' | 'flak' | 'ring' | 'spark' | 'fire' | 'flash' | 'chaff' | 'trail' | 'contrail' | 'debris' | 'chute'
  | 'fireball' | 'dust' | 'ember' | 'tracer' | 'column' | 'booster' | 'shock';

export interface Particle {
  on: boolean;
  kind: PKind;
  x: number;
  y: number;
  vx: number;
  vy: number;
  alt: number;
  life: number;
  max: number;
  size: number;
  grow: number;
  hue: number;
  x2: number;
  y2: number;
  vz: number; // altitude change per second
}

export class FxPool {
  readonly items: Particle[];
  private cursor = 0;
  windX = 0;
  windY = 0;

  constructor(n = CONFIG.fx.maxParticles) {
    this.items = Array.from({ length: n }, () => ({
      on: false, kind: 'smoke' as PKind, x: 0, y: 0, vx: 0, vy: 0, alt: 0, life: 0, max: 1, size: 0.1, grow: 0, hue: 0, x2: 0, y2: 0, vz: 0,
    }));
  }

  spawn(kind: PKind, x: number, y: number, o: Partial<Particle> = {}): Particle {
    // Ring buffer: when full, the oldest particle is recycled.
    let p = this.items[this.cursor];
    for (let i = 0; i < 8 && p.on; i++) {
      this.cursor = (this.cursor + 1) % this.items.length;
      p = this.items[this.cursor];
    }
    this.cursor = (this.cursor + 1) % this.items.length;
    p.on = true;
    p.kind = kind;
    p.x = x;
    p.y = y;
    p.vx = o.vx ?? 0;
    p.vy = o.vy ?? 0;
    p.alt = o.alt ?? 0;
    p.max = p.life = o.life ?? 1;
    p.size = o.size ?? 0.1;
    p.grow = o.grow ?? 0;
    p.hue = o.hue ?? 0;
    p.x2 = o.x2 ?? x;
    p.y2 = o.y2 ?? y;
    p.vz = o.vz ?? 0;
    return p;
  }

  update(dt: number): void {
    for (const p of this.items) {
      if (!p.on) continue;
      p.life -= dt;
      if (p.life <= 0) {
        p.on = false;
        continue;
      }
      const drift = p.kind === 'smoke' || p.kind === 'flak' || p.kind === 'chaff' || p.kind === 'trail' || p.kind === 'contrail' || p.kind === 'dust' ? 1 : 0.2;
      p.x += (p.vx + this.windX * drift) * dt;
      p.y += (p.vy + this.windY * drift) * dt;
      if (p.kind === 'tracer') {
        p.x2 += (p.vx + this.windX * drift) * dt;
        p.y2 += (p.vy + this.windY * drift) * dt;
      }
      p.size += p.grow * dt;
      if (p.kind === 'spark' || p.kind === 'debris' || p.kind === 'ember') {
        p.vx *= 1 - dt * 1.5;
        p.vy *= 1 - dt * 1.5;
      }
      if (p.kind === 'chute') p.alt = Math.max(0, p.alt - dt * 0.12);
      if (p.vz) {
        p.alt = Math.max(0, p.alt + p.vz * dt);
        if (p.kind === 'column' || p.kind === 'fireball') p.vz *= 1 - dt * 0.6;
        if (p.kind === 'booster') p.vz -= dt * 0.25;
      }
    }
  }

  count(): number {
    let n = 0;
    for (const p of this.items) if (p.on) n++;
    return n;
  }

  // ── Composite effects ──
  flakBurst(x: number, y: number, alt: number, heavy: boolean): void {
    const s = heavy ? 0.22 : 0.1;
    this.spawn('flash', x, y, { life: 0.12, size: s * 1.6, alt });
    // The red core of the detonation shows for an instant inside the black cloud.
    if (heavy) this.spawn('fireball', x, y, { life: 0.22, size: s * 0.6, grow: s * 2, alt, hue: 1 });
    this.spawn('flak', x, y, { life: heavy ? 9 : 4, size: s, grow: heavy ? 0.05 : 0.03, alt });
    if (heavy) this.spawn('ring', x, y, { life: 0.5, size: 0.05, grow: 1.1, alt });
    const n = heavy ? 6 : 2;
    for (let i = 0; i < n; i++) {
      const a = Math.random() * Math.PI * 2;
      const v = 0.6 + Math.random() * 0.9;
      this.spawn('spark', x, y, { vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.35, size: 0.05, alt });
    }
  }

  bombBlast(x: number, y: number, kg: number, incendiary: boolean, night: boolean): void {
    const s = 0.12 + Math.sqrt(kg) * 0.006;
    this.spawn('flash', x, y, { life: 0.18, size: s * 3 });
    this.spawn('fireball', x, y, { life: 0.7, size: s, grow: s * 1.6, hue: incendiary ? 1 : 0, vz: 0.4 });
    // Condensation shock ring racing outward, then the dirt column climbing.
    this.spawn('shock', x, y, { life: 0.3, size: s * 0.5, grow: s * 14 });
    this.spawn('ring', x, y, { life: 0.45, size: s, grow: s * 5 });
    for (let i = 0; i < 3; i++) this.spawn('column', x + (Math.random() - 0.5) * s, y + (Math.random() - 0.5) * s, { life: 3 + Math.random() * 2, size: s * 0.7, grow: s * 0.5, vz: 0.35 + Math.random() * 0.3 });
    for (let i = 0; i < 4; i++) {
      const a = Math.random() * Math.PI * 2;
      const v = 0.3 + Math.random() * 0.8;
      this.spawn('debris', x, y, { vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.8, size: 0.04 });
    }
    this.spawn('smoke', x + (Math.random() - 0.5) * 0.2, y + (Math.random() - 0.5) * 0.2, { life: 14, size: s * 1.2, grow: 0.06, hue: night ? 1 : 0 });
    if (incendiary) for (let i = 0; i < 4; i++) this.spawn('fire', x + (Math.random() - 0.5) * s * 3, y + (Math.random() - 0.5) * s * 3, { life: 3 + Math.random() * 3, size: s * 0.9 });
  }

  secondary(x: number, y: number, big: boolean): void {
    const s = big ? 0.9 : 0.45;
    this.spawn('flash', x, y, { life: 0.35, size: s * 3 });
    this.spawn('fireball', x, y, { life: big ? 2.6 : 1.4, size: s * 0.4, grow: s * 1.1, hue: 1 });
    // Mushroom: a second fireball rising under a dark cap.
    if (big) {
      this.spawn('fireball', x, y, { life: 3.2, size: s * 0.35, grow: s * 0.6, hue: 1, vz: 0.9 });
      this.spawn('smoke', x, y, { life: 20, size: s * 0.5, grow: 0.12, hue: 1, alt: 0.2, vz: 0.6 });
    }
    this.spawn('shock', x, y, { life: 0.4, size: s * 0.3, grow: s * 8 });
    this.spawn('ring', x, y, { life: 0.8, size: s * 0.3, grow: s * 6 });
    for (let i = 0; i < (big ? 14 : 7); i++) {
      const a = Math.random() * Math.PI * 2;
      const v = 0.4 + Math.random() * 1.4;
      this.spawn('ember', x, y, { vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 1.2 + Math.random(), size: 0.05 });
    }
    this.spawn('smoke', x, y, { life: 22, size: s * 0.8, grow: 0.09, hue: 1 });
  }

  missileDet(x: number, y: number, alt: number): void {
    this.spawn('flash', x, y, { life: 0.2, size: 1.0, alt });
    this.spawn('fireball', x, y, { life: 0.6, size: 0.15, grow: 0.5, alt });
    this.spawn('ring', x, y, { life: 0.6, size: 0.1, grow: 1.4, alt });
    for (let i = 0; i < 18; i++) {
      const a = (i / 18) * Math.PI * 2 + Math.random() * 0.2;
      const v = 1.6 + Math.random() * 0.8;
      this.spawn('spark', x, y, { vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.45, size: 0.06, alt });
    }
    this.spawn('smoke', x, y, { life: 8, size: 0.2, grow: 0.06, alt });
  }

  /** Solid-fuel launch: a wide dust cloud blasted out across the ground. */
  launchCloud(x: number, y: number): void {
    this.spawn('flash', x, y, { life: 0.6, size: 1.8 });
    for (let i = 0; i < 16; i++) {
      const a = Math.random() * Math.PI * 2;
      const v = 0.15 + Math.random() * 0.35;
      this.spawn('dust', x, y, { vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 6 + Math.random() * 4, size: 0.18, grow: 0.12 });
    }
  }

  /** The spent booster tumbling away after separation, trailing smoke. */
  boosterDrop(x: number, y: number, alt: number, hdg: number): void {
    this.spawn('booster', x, y, { vx: Math.cos(hdg) * 0.3, vy: Math.sin(hdg) * 0.3, alt, vz: 0.05, life: 6, size: 0.12 });
    this.spawn('flash', x, y, { life: 0.15, size: 0.6, alt });
  }

  chaffBloom(x: number, y: number, alt: number, heading: number): void {
    for (let i = 0; i < CONFIG.fx.chaffBundlesPerPop; i++) {
      const a = heading + Math.PI + (Math.random() - 0.5) * 1.6;
      const v = 0.1 + Math.random() * 0.5;
      this.spawn('chaff', x, y, { vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 4 + Math.random() * 8, size: 0.02 + Math.random() * 0.03, alt });
    }
  }

  planeDown(x: number, y: number, alt: number, chutes: number): void {
    this.spawn('flash', x, y, { life: 0.25, size: 0.8, alt });
    this.spawn('fireball', x, y, { life: 1.2, size: 0.2, grow: 0.5, hue: 1, alt });
    for (let i = 0; i < 10; i++) {
      const a = Math.random() * Math.PI * 2;
      const v = 0.3 + Math.random() * 0.9;
      this.spawn('debris', x, y, { vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 1.6, size: 0.05, alt });
    }
    for (let i = 0; i < chutes; i++) {
      this.spawn('chute', x + (Math.random() - 0.5) * 0.6, y + (Math.random() - 0.5) * 0.6, { life: 40, size: 0.06, alt });
    }
  }
}

/** Screen-space state the renderer applies every frame. */
export class ScreenFx {
  shake = 0;
  flash = 0;
  vignette = 0;
  glare = 0;
  slowmo = 0;
  dirt = 0;
  ambientShake = 0;

  kick(amount: number): void {
    this.shake = Math.min(28, Math.max(this.shake, amount));
  }

  update(dt: number): void {
    this.shake = Math.max(0, this.shake - dt * 22);
    this.flash = Math.max(0, this.flash - dt * 2.2);
    this.vignette = Math.max(0, this.vignette - dt * 1.2);
    this.glare = Math.max(0, this.glare - dt * 1.5);
    this.slowmo = Math.max(0, this.slowmo - dt);
    this.dirt = Math.max(0, this.dirt - dt * 0.35);
  }

  offset(): [number, number] {
    const a = (this.shake + this.ambientShake) * prefs.motion;
    return [(Math.random() - 0.5) * a, (Math.random() - 0.5) * a];
  }
}