โ Public packages
@kentcdodds/asteroids
Classic canvas Asteroids game with vector graphics, touch controls, and a saved high score.
scripts/render-art.mjs
70 lines ยท 5.9 KB ยท JavaScript// Renders og image, favicons, PWA icons, and .kody/icon.png with headless Chrome.
// Usage: node scripts/render-art.mjs (needs playwright-core + a Chrome; set CHROME_PATH if not /usr/bin/google-chrome)
import { chromium } from 'playwright-core'
import { writeFileSync, mkdirSync } from 'node:fs'
const BG = '#05070d', INK = '#e8f1ff', GLOW = '#7fb4ff'
// Deterministic pseudo-random so renders are reproducible.
let seed = 7; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647)
const rockPath = (cx, cy, r, n = 11) => {
const pts = []; for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2, k = 0.72 + rnd() * 0.36; pts.push([cx + Math.cos(a) * r * k, cy + Math.sin(a) * r * k]) }
return 'M' + pts.map((p) => p.map((v) => v.toFixed(1)).join(' ')).join(' L') + ' Z'
}
const ship = (x, y, s, rot, px = 3) => `<g transform="translate(${x} ${y}) rotate(${rot}) scale(${s})"><path d="M16 0 L-11 -10 L-6 0 L-11 10 Z" fill="none" stroke="${INK}" stroke-width="${px / s}" stroke-linejoin="round"/></g>`
const flame = (x, y, s, rot, px = 3) => `<g transform="translate(${x} ${y}) rotate(${rot}) scale(${s})"><path d="M-7 -5 L-20 0 L-7 5" fill="none" stroke="#ffb86b" stroke-width="${px / s}" stroke-linejoin="round"/></g>`
const glowDefs = (b) => `<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${b}" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<radialGradient id="v" cx="50%" cy="45%" r="75%"><stop offset="0" stop-color="#0d1630"/><stop offset="1" stop-color="${BG}"/></radialGradient></defs>`
function ogSvg() {
seed = 11
const stars = Array.from({ length: 140 }, () => `<circle cx="${(rnd() * 1200).toFixed(1)}" cy="${(rnd() * 630).toFixed(1)}" r="${(rnd() * 1.3 + 0.3).toFixed(2)}" fill="${INK}" opacity="${(rnd() * 0.5 + 0.15).toFixed(2)}"/>`).join('')
const rocks = [[150, 500, 95], [1060, 480, 105], [1110, 70, 42], [70, 70, 34], [340, 440, 22], [1165, 300, 18], [960, 395, 22], [250, 400, 18]]
.map(([x, y, r]) => `<path d="${rockPath(x, y, r)}" fill="none" stroke="${INK}" stroke-width="2.6" stroke-linejoin="round"/>`).join('')
const bullets = [[720, 438], [762, 408], [804, 378]].map(([x, y]) => `<circle cx="${x}" cy="${y}" r="4" fill="${INK}"/>`).join('')
const debris = Array.from({ length: 18 }, () => { const a = rnd() * 6.28, d = 20 + rnd() * 60; return `<rect x="${(870 + Math.cos(a) * d).toFixed(1)}" y="${(330 + Math.sin(a) * d).toFixed(1)}" width="3" height="3" fill="${INK}" opacity="${(0.4 + rnd() * 0.6).toFixed(2)}"/>` }).join('')
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">${glowDefs(5)}
<rect width="1200" height="630" fill="url(#v)"/>${stars}
<g filter="url(#g)">${rocks}${bullets}${debris}${flame(640, 480, 4.5, -35, 3.2)}${ship(640, 480, 4.5, -35, 3.2)}</g>
<g filter="url(#g)" font-family="ui-monospace, 'DejaVu Sans Mono', Menlo, monospace" font-weight="700" fill="none" stroke="${INK}" text-anchor="middle">
<text x="600" y="235" font-size="132" letter-spacing="34" stroke-width="3">ASTEROIDS</text></g>
<g font-family="ui-monospace, 'DejaVu Sans Mono', Menlo, monospace" fill="${INK}" text-anchor="middle">
<text x="600" y="300" font-size="26" letter-spacing="10" opacity="0.8">VECTOR ARCADE ยท KEYBOARD OR TOUCH</text>
<text x="600" y="600" font-size="22" letter-spacing="6" opacity="0.55">ASTEROIDS.KENTCDODDS.COM</text></g></svg>`
}
// Icon: glowing ship flying past an asteroid. `pad` shrinks content into the maskable safe zone.
function iconSvg(size, { pad = 0, round = true } = {}) {
seed = 3
const k = 1 - pad * 2, o = 256 * pad * 2
const inner = `<path d="${rockPath(345, 350, 105, 10)}" fill="none" stroke="${INK}" stroke-width="13" stroke-linejoin="round"/>
${flame(175, 190, 6.5, -40, 12)}${ship(175, 190, 6.5, -40, 13)}<circle cx="280" cy="105" r="10" fill="${INK}"/><circle cx="345" cy="58" r="9" fill="${INK}" opacity="0.8"/>`
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512">${glowDefs(9)}
<rect width="512" height="512" ${round ? 'rx="96"' : ''} fill="url(#v)"/>
<g transform="translate(${o / 2} ${o / 2}) scale(${k})" filter="url(#g)">${inner}</g></svg>`
}
// Tiny favicon: just the ship, thicker strokes, readable at 16-32px.
function faviconSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="${BG}"/><g transform="translate(15 17) rotate(-45) scale(0.85)"><path d="M14 0 L-10 -9 L-5 0 L-10 9 Z" fill="none" stroke="${INK}" stroke-width="2.6" stroke-linejoin="round"/></g><circle cx="25" cy="7" r="2" fill="${GLOW}"/></svg>`
}
const out = {
'public/og.jpg': [ogSvg(), 1200, 630],
'public/icons/icon-192.png': [iconSvg(192), 192, 192],
'public/icons/icon-512.png': [iconSvg(512), 512, 512],
'public/icons/icon-maskable-512.png': [iconSvg(512, { pad: 0.1, round: false }), 512, 512],
'public/icons/apple-touch-icon.png': [iconSvg(180, { round: false }), 180, 180],
'public/icons/favicon-32.png': [faviconSvg(), 32, 32],
'.kody/icon.png': [iconSvg(512), 512, 512],
}
mkdirSync('public/icons', { recursive: true })
writeFileSync('public/icons/favicon.svg', faviconSvg())
writeFileSync('.kody/icon.svg', iconSvg(512))
const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/usr/bin/google-chrome' })
for (const [file, [svg, w, h]] of Object.entries(out)) {
const page = await browser.newPage({ viewport: { width: w, height: h } })
await page.setContent(`<html><body style="margin:0;background:transparent">${svg.replace(/width="\d+" height="\d+" viewBox/, `width="${w}" height="${h}" viewBox`)}</body></html>`)
const jpeg = file.endsWith('.jpg')
await page.screenshot({ path: file, type: jpeg ? 'jpeg' : 'png', quality: jpeg ? 86 : undefined, omitBackground: !jpeg, clip: { x: 0, y: 0, width: w, height: h } })
await page.close(); console.log('wrote', file)
}
await browser.close()