Files

225 lines
8.5 KiB
JavaScript

// Generate 8-bit style thumbnails for each game (attract-mode look).
// Self-contained: all code inlined into a single page, no cross-scope issues.
const puppeteer = require('puppeteer');
const path = require('path');
const fs = require('fs');
const OUT = path.join(__dirname, '..', 'public', 'img');
fs.mkdirSync(OUT, { recursive: true });
const W = 640, H = 480;
const PAGE = `<!DOCTYPE html><html><head>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
</head><body style="margin:0;background:#000">
<canvas id="c" width="${W}" height="${H}"></canvas>
<script>
const W = ${W}, H = ${H};
function stars(ctx, n, seed) {
let s = seed;
const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647;
for (let i = 0; i < n; i++) {
const roll = rnd();
if (roll < 0.15) ctx.fillStyle = '#ff2d95';
else if (rnd() < 0.3) ctx.fillStyle = '#7df9ff';
else ctx.fillStyle = '#ffffff';
ctx.globalAlpha = 0.3 + rnd() * 0.7;
ctx.fillRect(rnd() * W, rnd() * H, rnd() < 0.2 ? 2 : 1, rnd() < 0.2 ? 2 : 1);
}
ctx.globalAlpha = 1;
}
function crt(ctx) {
ctx.globalAlpha = 0.08;
ctx.fillStyle = '#000';
for (let y = 0; y < H; y += 3) ctx.fillRect(0, y, W, 1);
ctx.globalAlpha = 1;
const g = ctx.createRadialGradient(W/2, H/2, H*0.4, W/2, H/2, H*0.9);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.4)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function title(ctx, text, y, color) {
ctx.font = '28px "Press Start 2P", monospace';
ctx.textAlign = 'center';
ctx.shadowColor = color;
ctx.shadowBlur = 16;
ctx.fillStyle = color;
ctx.fillText(text, W/2, y);
ctx.shadowBlur = 0;
}
function ship(ctx, x, y, a, scale) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.scale(scale, scale);
const map = ['...W...','..WWW..','..WWW..','.WWWWWW','WWWWWWWW','W.W..W.W'];
ctx.fillStyle = '#e8f4ff';
map.forEach((row, yy) => row.split('').forEach((c, xx) => { if (c === 'W') ctx.fillRect(xx - 3.5, yy - 3, 1, 1); }));
ctx.restore();
}
function asteroid(ctx, x, y, r, rot, verts) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.strokeStyle = '#c9b8ff';
ctx.lineWidth = 2;
ctx.beginPath();
verts.forEach((v, i) => {
const ang = (i / verts.length) * Math.PI * 2;
const px = Math.cos(ang) * r * v, py = Math.sin(ang) * r * v;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function invader(ctx, x, y, type, scale) {
const maps = {
1: ['..X...X..','...X.X...','..XXXXX..','.XX.X.XX.','XXXXXXXXX','X.XXXXX.X','X.X...X.X','...X.X...'],
2: ['...X.X...','..XXXXX..','.XXXXXXX.','XX.XXX.XX','XXXXXXXXX','..X...X..','.X.X.X.X.','X..X.X..X'],
3: ['..X...X..','.XXXXXXX.','XXXXXXXXX','XXX.X.XXX','XXXXXXXXX','.X.....X.','X..X.X..X','.X.....X.'],
};
const colors = { 1: '#7dff5e', 2: '#ff7de9', 3: '#ffe14d' };
const map = maps[type];
ctx.fillStyle = colors[type];
map.forEach((row, yy) => row.split('').forEach((c, xx) => {
if (c === 'X') ctx.fillRect(x - 3.5 * scale + xx * scale, y - 4 * scale + yy * scale, scale, scale);
}));
}
function drawScene(name) {
const ctx = document.getElementById('c').getContext('2d');
ctx.setTransform(1,0,0,1,0,0);
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
ctx.fillStyle = '#05010d';
ctx.fillRect(0, 0, W, H);
if (name === 'asteroid') {
stars(ctx, 90, 42);
const verts = [0.8, 1.2, 0.7, 1.1, 0.9, 1.3, 0.75, 1.05, 0.85];
asteroid(ctx, 150, 140, 34, 0.4, verts);
asteroid(ctx, 480, 110, 22, 1.2, verts);
asteroid(ctx, 520, 330, 40, 2.1, verts);
asteroid(ctx, 220, 360, 18, 0.8, verts);
asteroid(ctx, 380, 250, 14, 1.7, verts);
ship(ctx, 320, 240, -Math.PI/2, 3);
ctx.fillStyle = '#ffe14d';
ctx.fillRect(320, 180, 3, 3);
ctx.fillRect(320, 150, 3, 3);
const ufo = ['...GGGG...','..GGGGGG..','.GGGGGGGG.','GGGRRRRGGG','GGGGGGGGGG','.G.G..G.G.'];
ufo.forEach((row, yy) => row.split('').forEach((c, xx) => {
ctx.fillStyle = c === 'R' ? '#ff4d6d' : '#9d7dff';
ctx.fillRect(90 + xx * 3, 380 + yy * 3, 3, 3);
}));
title(ctx, 'ASTEROID FIELD', 60, '#ff2d95');
}
else if (name === 'breakout') {
stars(ctx, 60, 77);
const colors = ['#ff2d95', '#ff7de9', '#9d7dff', '#7df9ff', '#7dff5e', '#ffe14d', '#ff9d2d', '#ff4d6d'];
const BW = 44, BH = 14, GAP = 4;
const ox = (W - 12 * (BW + GAP)) / 2;
for (let r = 0; r < 8; r++) {
for (let c = 0; c < 12; c++) {
if (r === 3 && c > 3 && c < 8) continue;
ctx.fillStyle = colors[r % colors.length];
ctx.globalAlpha = 0.5 + 0.5 * ((r + c) % 3) / 2;
ctx.fillRect(ox + c * (BW + GAP), 90 + r * (BH + GAP), BW, BH);
}
}
ctx.globalAlpha = 1;
ctx.fillStyle = '#7df9ff';
ctx.fillRect(W/2 - 45, H - 60, 90, 10);
ctx.fillStyle = '#fff';
ctx.fillRect(W/2 - 45, H - 60, 90, 3);
ctx.beginPath(); ctx.arc(W/2 + 60, H - 140, 6, 0, Math.PI * 2); ctx.fill();
title(ctx, 'NEON BREAKER', 50, '#7df9ff');
}
else if (name === 'snake') {
stars(ctx, 50, 13);
const CELL = 18, COLS = 30, ROWS = 20;
const OX = (W - COLS * CELL) / 2, OY = (H - ROWS * CELL) / 2 + 20;
ctx.strokeStyle = '#3a2f5b'; ctx.lineWidth = 2;
ctx.strokeRect(OX - 2, OY - 2, COLS * CELL + 4, ROWS * CELL + 4);
ctx.strokeStyle = 'rgba(58,47,91,0.25)'; ctx.lineWidth = 1;
for (let x = 0; x <= COLS; x++) { ctx.beginPath(); ctx.moveTo(OX + x * CELL, OY); ctx.lineTo(OX + x * CELL, OY + ROWS * CELL); ctx.stroke(); }
for (let y = 0; y <= ROWS; y++) { ctx.beginPath(); ctx.moveTo(OX, OY + y * CELL); ctx.lineTo(OX + COLS * CELL, OY + y * CELL); ctx.stroke(); }
const body = [[8,10],[9,10],[10,10],[11,10],[11,9],[11,8],[12,8],[13,8],[14,8],[14,9],[14,10],[14,11],[15,11],[16,11]];
body.forEach((p, i) => {
const t = i / body.length;
ctx.fillStyle = i === 0 ? '#7dff5e' : 'rgba(125,255,94,' + (1 - t * 0.55) + ')';
ctx.fillRect(OX + p[0] * CELL + 1, OY + p[1] * CELL + 1, CELL - 2, CELL - 2);
});
ctx.fillStyle = '#05010d';
ctx.fillRect(OX + 8 * CELL + 10, OY + 10 * CELL + 4, 3, 3);
ctx.fillRect(OX + 8 * CELL + 10, OY + 10 * CELL + 11, 3, 3);
ctx.fillStyle = '#ffe14d';
ctx.fillRect(OX + 22 * CELL + 4, OY + 6 * CELL + 4, CELL - 8, CELL - 8);
ctx.fillStyle = '#ff9d2d';
ctx.fillRect(OX + 4 * CELL + 3, OY + 15 * CELL + 3, CELL - 6, CELL - 6);
title(ctx, 'SNAKE PROTOCOL', 50, '#7dff5e');
}
else if (name === 'spaceinvaders') {
stars(ctx, 70, 99);
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 10; c++) {
if (r === 2 && c === 4) continue;
const type = r === 0 ? 3 : r < 3 ? 1 : 2;
invader(ctx, 120 + c * 44, 110 + r * 36, type, 3);
}
}
ctx.fillStyle = '#7dff5e';
[W * 0.2, W * 0.4, W * 0.6, W * 0.8].forEach(px => {
for (let r = 0; r < 4; r++) for (let c = 0; c < 6; c++) {
if (r === 3 && (c === 0 || c === 5)) continue;
if (r === 2 && (c === 0 || c === 5)) continue;
ctx.fillRect(px - 30 + c * 10, H - 110 + r * 8, 10, 8);
}
});
ctx.fillStyle = '#7df9ff';
ctx.fillRect(W/2 - 18, H - 50, 36, 14);
ctx.fillRect(W/2 - 4, H - 66, 8, 18);
ctx.fillStyle = '#ffe14d';
ctx.fillRect(W/2 - 1.5, H - 160, 3, 10);
ctx.fillStyle = '#ff4d6d';
ctx.fillRect(300, 260, 3, 8);
ctx.fillRect(480, 300, 3, 8);
title(ctx, 'INVADER DOCK', 55, '#ffe14d');
}
crt(ctx);
}
</script></body></html>`;
const NAMES = ['asteroid', 'breakout', 'snake', 'spaceinvaders'];
(async () => {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-gpu',
'--use-gl=swiftshader','--enable-unsafe-swiftshader'],
});
const page = await browser.newPage();
await page.setViewport({ width: W, height: H });
await page.setContent(PAGE, { waitUntil: 'networkidle0' });
await page.evaluate(() => document.fonts.load('28px "Press Start 2P"'));
await page.evaluate(() => document.fonts.ready);
for (const name of NAMES) {
await page.evaluate((n) => drawScene(n), name);
const d = await page.$eval('#c', c => c.toDataURL('image/png'));
const b64 = d.replace(/^data:image\/png;base64,/, '');
const buf = Buffer.from(b64, 'base64');
fs.writeFileSync(path.join(OUT, name + '.png'), buf);
console.log(`wrote ${name}.png (${buf.length} bytes)`);
}
await browser.close();
console.log('thumbnails done');
})().catch(e => { console.error(e); process.exit(1); });