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.
// Cached sprites: soft glows, smoke puffs, clouds and detailed aircraft plan views.
// Drawing a cached canvas with drawImage is far cheaper than a gradient per particle.
import { GLYPHS } from '../ui/glyphs';
type Canvas = HTMLCanvasElement;
function make(w: number, h: number): [Canvas, CanvasRenderingContext2D] {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return [c, c.getContext('2d')!];
}
const glowCache = new Map<string, Canvas>();
/** Radial glow, white-hot core fading to the given rgb. */
export function glow(r: number, g: number, b: number, core = 0.35): Canvas {
const key = `${r},${g},${b},${core}`;
let c = glowCache.get(key);
if (c) return c;
const [cv, ctx] = make(64, 64);
const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, `rgba(255,255,240,1)`);
grad.addColorStop(core, `rgba(${r},${g},${b},0.85)`);
grad.addColorStop(0.7, `rgba(${r},${g},${b},0.25)`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 64, 64);
glowCache.set(key, cv);
c = cv;
return c;
}
const puffCache = new Map<string, Canvas>();
/** Lumpy smoke puff (several overlapping soft blobs). */
export function puff(r: number, g: number, b: number, seed = 0): Canvas {
const key = `${r},${g},${b},${seed % 4}`;
const hit = puffCache.get(key);
if (hit) return hit;
const [cv, ctx] = make(64, 64);
let s = 1 + (seed % 4) * 7919;
const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
for (let i = 0; i < 7; i++) {
const x = 32 + (rnd() - 0.5) * 26;
const y = 32 + (rnd() - 0.5) * 26;
const rad = 10 + rnd() * 12;
const grad = ctx.createRadialGradient(x - 3, y - 3, 0, x, y, rad);
const lift = 18;
grad.addColorStop(0, `rgba(${Math.min(255, r + lift)},${Math.min(255, g + lift)},${Math.min(255, b + lift)},0.55)`);
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, rad, 0, Math.PI * 2);
ctx.fill();
}
puffCache.set(key, cv);
return cv;
}
let cloudSprites: Canvas[] | null = null;
export function clouds(): Canvas[] {
if (cloudSprites) return cloudSprites;
cloudSprites = [];
for (let k = 0; k < 4; k++) {
const [cv, ctx] = make(256, 160);
let s = 31 + k * 977;
const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
for (let i = 0; i < 26; i++) {
const x = 40 + rnd() * 176;
const y = 50 + rnd() * 60;
const rad = 18 + rnd() * 34;
const grad = ctx.createRadialGradient(x, y - rad * 0.3, 0, x, y, rad);
grad.addColorStop(0, 'rgba(255,255,255,0.5)');
grad.addColorStop(0.6, 'rgba(235,240,245,0.25)');
grad.addColorStop(1, 'rgba(220,228,235,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, rad, 0, Math.PI * 2);
ctx.fill();
}
cloudSprites.push(cv);
}
return cloudSprites;
}
// ── Aircraft plan-view art ─────────────────────────────────────────────────
const SOVIET = new Set(['Tu-16', 'Tu-16PP', 'Tu-22', 'Tu-95', 'Il-28', 'Yak-28', 'MiG-21', 'Su-7', 'MiG-25R', 'drone']);
const planeCache = new Map<string, Canvas>();
const PAINT: Record<string, { body: string; dark: string; light: string }> = {
soviet: { body: '#a9b2b4', dark: '#6f7a7e', light: '#dfe6e6' },
sovietCamo: { body: '#8d9a7a', dark: '#5a6650', light: '#c3cdb0' },
nato: { body: '#7d8a94', dark: '#4f5b64', light: '#b9c4cc' },
drone: { body: '#c9c2a3', dark: '#8a8466', light: '#efe9cf' },
};
/** Detailed top-down sprite, nose toward +x, 128px box. */
export function planeSprite(kind: string, player = false): Canvas {
const key = `${kind}|${player}`;
const hit = planeCache.get(key);
if (hit) return hit;
const S = 128;
const [cv, ctx] = make(S, S);
const poly = GLYPHS[kind] ?? GLYPHS['Tu-16'];
const soviet = SOVIET.has(kind);
const pal = kind === 'drone' ? PAINT.drone : soviet ? (['Su-7', 'MiG-21', 'Yak-28'].includes(kind) ? PAINT.sovietCamo : PAINT.soviet) : PAINT.nato;
const k = S * 0.46;
ctx.translate(S / 2, S / 2);
const path = () => {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 2) {
const x = poly[i] * k;
const y = poly[i + 1] * k;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
};
// Body with light from the top-left.
const grad = ctx.createLinearGradient(-k, -k, k, k);
grad.addColorStop(0, pal.light);
grad.addColorStop(0.5, pal.body);
grad.addColorStop(1, pal.dark);
path();
ctx.fillStyle = grad;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = player ? '#bff4ff' : 'rgba(20,24,26,0.85)';
ctx.stroke();
// Fuselage spine.
ctx.fillStyle = pal.light;
ctx.beginPath();
ctx.ellipse(-k * 0.05, 0, k * 0.85, k * 0.07, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
// Panel lines across wings.
ctx.strokeStyle = 'rgba(0,0,0,0.18)';
for (let i = -2; i <= 2; i++) {
ctx.beginPath();
ctx.moveTo(k * (0.15 + i * 0.12), -k * 0.9);
ctx.lineTo(k * (0.15 + i * 0.12) - k * 0.3, k * 0.9);
ctx.stroke();
}
// Canopy.
ctx.fillStyle = '#1d3440';
ctx.beginPath();
ctx.ellipse(k * 0.66, 0, k * 0.13, k * 0.045, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(160,220,255,0.55)';
ctx.beginPath();
ctx.ellipse(k * 0.69, -k * 0.012, k * 0.06, k * 0.018, 0, 0, Math.PI * 2);
ctx.fill();
// Engines / props.
if (kind === 'Tu-95') {
for (const y of [-0.62, -0.32, 0.32, 0.62]) {
ctx.fillStyle = '#55605f';
ctx.fillRect(k * 0.05, k * y - k * 0.04, k * 0.3, k * 0.08);
ctx.strokeStyle = 'rgba(30,30,30,0.6)';
ctx.beginPath();
ctx.moveTo(k * 0.37, k * y - k * 0.16);
ctx.lineTo(k * 0.37, k * y + k * 0.16);
ctx.stroke();
}
} else if (['Tu-16', 'Tu-16PP', 'Il-28', 'Yak-28'].includes(kind)) {
for (const y of [-0.28, 0.28]) {
ctx.fillStyle = '#5c6567';
ctx.beginPath();
ctx.ellipse(k * 0.05, k * y, k * 0.28, k * 0.07, 0, 0, Math.PI * 2);
ctx.fill();
}
} else if (kind === 'Tu-22') {
ctx.fillStyle = '#5c6567';
for (const y of [-0.12, 0.12]) {
ctx.beginPath();
ctx.ellipse(-k * 0.7, k * y, k * 0.2, k * 0.07, 0, 0, Math.PI * 2);
ctx.fill();
}
}
// Markings: red stars for the regiment, roundels for NATO.
const mark = (x: number, y: number) => {
if (kind === 'drone') return;
if (soviet) {
ctx.fillStyle = '#c4231c';
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 5;
const r = i % 2 ? k * 0.035 : k * 0.085;
ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r);
}
ctx.closePath();
ctx.fill();
} else {
for (const [r, c] of [[0.08, '#1f3a8a'], [0.055, '#ffffff'], [0.03, '#c4231c']] as [number, string][]) {
ctx.fillStyle = c;
ctx.beginPath();
ctx.arc(x, y, k * r, 0, Math.PI * 2);
ctx.fill();
}
}
};
mark(-k * 0.05, -k * 0.55);
mark(-k * 0.05, k * 0.55);
if (kind === 'Tu-16PP') {
ctx.fillStyle = '#3a4446';
ctx.fillRect(-k * 0.3, -k * 0.05, k * 0.5, k * 0.1);
}
planeCache.set(key, cv);
return cv;
}
/** A plan-view "recognition card" image (data URL) for menus. */
const cardCache = new Map<string, string>();
export function planeCard(kind: string, w = 160, h = 110): string {
const key = `${kind}|${w}x${h}`;
const hit = cardCache.get(key);
if (hit) return hit;
const [cv, ctx] = make(w * 2, h * 2);
ctx.scale(2, 2);
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, '#0b1a20');
g.addColorStop(1, '#050b0e');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(95,211,255,0.12)';
for (let x = 0; x < w; x += 12) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = 0; y < h; y += 12) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
const sp = planeSprite(kind);
const size = Math.min(w, h) * 1.05;
ctx.save();
ctx.translate(w / 2 + 6, h / 2 + 8);
ctx.rotate(-Math.PI / 2);
ctx.globalAlpha = 0.35;
ctx.filter = 'blur(3px)';
ctx.drawImage(sp, -size / 2 + 6, -size / 2 + 6, size, size);
ctx.restore();
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.rotate(-Math.PI / 2);
ctx.drawImage(sp, -size / 2, -size / 2, size, size);
ctx.restore();
const url = cv.toDataURL();
cardCache.set(key, url);
return url;
}
const shadowCache = new Map<string, HTMLCanvasElement>();
/** Black silhouette of a plane sprite, used for ground shadows. */
export function planeShadow(kind: string): HTMLCanvasElement {
const hit = shadowCache.get(kind);
if (hit) return hit;
const src = planeSprite(kind);
const [cv, ctx] = make(src.width, src.height);
ctx.drawImage(src, 0, 0);
ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, cv.width, cv.height);
shadowCache.set(kind, cv);
return cv;
}
let samSiteSprite: HTMLCanvasElement | null = null;
/** Six launch rails in sandbagged revetments around a guidance radar. */
export function samSite(): HTMLCanvasElement {
if (samSiteSprite) return samSiteSprite;
const [cv, ctx] = make(128, 128);
ctx.translate(64, 64);
ctx.fillStyle = 'rgba(120,110,80,0.55)';
ctx.beginPath();
ctx.arc(0, 0, 60, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(80,70,50,0.8)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, 18, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
const x = Math.cos(a) * 42;
const y = Math.sin(a) * 42;
ctx.fillStyle = '#6b6650';
ctx.beginPath();
ctx.arc(x, y, 12, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.fillStyle = '#e8e4d8';
ctx.fillRect(-9, -2, 18, 4);
ctx.fillStyle = '#c4231c';
ctx.fillRect(7, -2, 3, 4);
ctx.restore();
}
ctx.fillStyle = '#4b5046';
ctx.fillRect(-9, -7, 18, 14);
samSiteSprite = cv;
return cv;
}
const bankCache = new Map<string, HTMLCanvasElement>();
/**
* The same plane, lit for a roll: the dipping wing in shadow, the raised wing
* catching the light. side = 1 means the right wing (local +y) is down.
*/
export function planeBankShade(kind: string, player: boolean, side: 1 | -1): HTMLCanvasElement {
const key = `${kind}|${player}|${side}`;
const hit = bankCache.get(key);
if (hit) return hit;
const src = planeSprite(kind, player);
const [cv, ctx] = make(src.width, src.height);
ctx.drawImage(src, 0, 0);
ctx.globalCompositeOperation = 'source-atop';
const g = ctx.createLinearGradient(0, side > 0 ? 0 : cv.height, 0, side > 0 ? cv.height : 0);
g.addColorStop(0, 'rgba(255,255,245,0.45)');
g.addColorStop(0.45, 'rgba(255,255,245,0)');
g.addColorStop(0.55, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.6)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, cv.width, cv.height);
bankCache.set(key, cv);
return cv;
}
