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