Add thumbnail generator tool (Puppeteer-based)
This commit is contained in:
@@ -0,0 +1,225 @@
|
|||||||
|
// 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); });
|
||||||
Reference in New Issue
Block a user