polish: bolder placeholder art so thumbnails read at small size

Brighter backgrounds, thicker strokes, and large tonal regions (backlight
pool, lit doorway, face fill, water band) so the seed art is legible as
thumbnails instead of near-black cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-07-21 13:18:52 +01:00
co-authored by Claude Opus 4.8
parent 3bd85dd9c7
commit d430ee9574
6 changed files with 42 additions and 55 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+42 -55
View File
@@ -1,6 +1,6 @@
// Reproducible B&W placeholder art for the seed library (the 5 entries in // Reproducible B&W placeholder art for the seed library (the 5 entries in
// src/art/manifest.js). Pure Node — hand-rolled PNG encoder + analytic AA, // src/art/manifest.js). Pure Node — hand-rolled PNG encoder + analytic AA.
// atmospheric charcoal motifs. Swap for real pencil art anytime. // Bold charcoal motifs with large tonal regions so they read at thumbnail size.
// node tools/gen-art-placeholders.mjs // node tools/gen-art-placeholders.mjs
import { deflateSync } from 'node:zlib'; import { deflateSync } from 'node:zlib';
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
@@ -25,78 +25,65 @@ const ss = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return
function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function distSeg(px, py, ax, ay, bx, by) { const dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy; let t = l2 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0; t = clamp(t, 0, 1); return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); } function distSeg(px, py, ax, ay, bx, by) { const dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy; let t = l2 ? ((px - ax) * dx + (py - ay) * dy) / l2 : 0; t = clamp(t, 0, 1); return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); }
const AA = 1.1; const AA = 1.2;
const LIGHT = [206, 214, 222], DIM = [150, 160, 172], BRIGHT = [232, 238, 244], AMBER = [232, 200, 150]; const LIGHT = [222, 230, 238], DIM = [178, 188, 202], BRIGHT = [242, 246, 252], AMBER = [242, 208, 150], DARK = [9, 11, 15];
function canvas() { function canvas() {
const buf = Buffer.alloc(W * H * 4); const buf = Buffer.alloc(W * H * 4);
const api = { return {
buf, buf,
bg(topRGB, botRGB) { for (let y = 0; y < H; y++) { const t = y / (H - 1); const c = [lerp(topRGB[0], botRGB[0], t), lerp(topRGB[1], botRGB[1], t), lerp(topRGB[2], botRGB[2], t)]; for (let x = 0; x < W; x++) { const i = (y * W + x) * 4; buf[i] = c[0]; buf[i + 1] = c[1]; buf[i + 2] = c[2]; buf[i + 3] = 255; } } }, bg(top, bot) { for (let y = 0; y < H; y++) { const t = y / (H - 1); const c = [lerp(top[0], bot[0], t), lerp(top[1], bot[1], t), lerp(top[2], bot[2], t)]; for (let x = 0; x < W; x++) { const i = (y * W + x) * 4; buf[i] = c[0]; buf[i + 1] = c[1]; buf[i + 2] = c[2]; buf[i + 3] = 255; } } },
each(fn) { for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const i = (y * W + x) * 4; const a = fn(x + 0.5, y + 0.5); if (!a) continue; const [col, alpha] = a; if (alpha <= 0) continue; buf[i] = lerp(buf[i], col[0], alpha); buf[i + 1] = lerp(buf[i + 1], col[1], alpha); buf[i + 2] = lerp(buf[i + 2], col[2], alpha); } }, each(fn) { for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const r = fn(x + 0.5, y + 0.5); if (!r) continue; const [col, a] = r; if (!(a > 0)) continue; const i = (y * W + x) * 4; buf[i] = lerp(buf[i], col[0], a); buf[i + 1] = lerp(buf[i + 1], col[1], a); buf[i + 2] = lerp(buf[i + 2], col[2], a); } },
}; };
return api;
} }
const segCov = (px, py, a, hw) => (col, alpha) => [col, (1 - ss(hw - AA, hw + AA, distSeg(px, py, a[0], a[1], a[2], a[3]))) * alpha]; const seg = (px, py, a, hw, col, alpha) => [col, (1 - ss(hw - AA, hw + AA, distSeg(px, py, a[0], a[1], a[2], a[3]))) * alpha];
const discCov = (px, py, cx, cy, r) => 1 - ss(r - AA, r + AA, Math.hypot(px - cx, py - cy)); const disc = (px, py, cx, cy, r) => 1 - ss(r - AA, r + AA, Math.hypot(px - cx, py - cy));
const scenes = { const scenes = {
// Detective in alley — converging walls, backlit figure.
'detective-alley'(c) { 'detective-alley'(c) {
c.bg([24, 27, 32], [12, 15, 20]); c.bg([44, 49, 58], [15, 19, 25]);
c.each((x, y) => { c.each((x, y) => { const g = Math.max(0, 1 - Math.hypot((x - 240) / 155, (y - 505) / 150)); return [[132, 140, 152], Math.pow(g, 1.4) * 0.75]; }); // backlight pool
let a = 0, col = LIGHT; c.each((x, y) => seg(x, y, [28, H, 236, 168], 3.5, LIGHT, 0.9));
const halo = Math.max(0, 1 - Math.hypot(x - 240, y - 500) / 150) * 0.4; // backlight c.each((x, y) => seg(x, y, [452, H, 244, 168], 3.5, LIGHT, 0.9));
a = halo; col = [90, 96, 104]; c.each((x, y) => seg(x, y, [78, 566, 402, 566], 3, LIGHT, 0.75));
return [col, a]; c.each((x, y) => { const head = disc(x, y, 240, 446, 21); const hw = lerp(27, 42, ss(466, 566, y)); const body = (1 - ss(hw - AA, hw + AA, Math.abs(x - 240))) * ss(462, 466, y) * (1 - ss(560, 564, y)); return [DARK, Math.max(head, body) * 0.96]; });
});
c.each((x, y) => segCov(x, y, [40, H, 232, 175], 2)(DIM, 0.6)); // left perspective edge
c.each((x, y) => segCov(x, y, [440, H, 248, 175], 2)(DIM, 0.6)); // right perspective edge
c.each((x, y) => segCov(x, y, [90, 560, 390, 560], 2)(DIM, 0.5)); // ground
c.each((x, y) => { // figure silhouette: head + tapered body
const head = discCov(x, y, 240, 452, 16);
const halfW = lerp(20, 30, ss(470, 560, y)); // shoulders widen downward
const body = (1 - ss(halfW - AA, halfW + AA, Math.abs(x - 240))) * ss(468, 472, y) * (1 - ss(556, 560, y));
return [[8, 9, 12], Math.max(head, body)];
});
}, },
// Neon street — vertical light bars + reflections.
'neon-street'(c) { 'neon-street'(c) {
c.bg([20, 22, 30], [10, 12, 18]); c.bg([34, 37, 48], [11, 13, 20]);
const bars = [[80, 70, 300, LIGHT], [150, 110, 330, AMBER], [300, 60, 280, BRIGHT], [372, 130, 340, LIGHT], [420, 90, 300, AMBER]]; c.each((x, y) => [[64, 72, 100], (1 - ss(70, 200, Math.abs(y - 400))) * 0.55]); // street glow band
for (const [bx, y0, y1, col] of bars) { for (const [bx, y0, y1, col, w] of [[70, 58, 300, LIGHT, 8], [150, 100, 340, AMBER, 7], [240, 48, 288, BRIGHT, 11], [330, 118, 350, LIGHT, 7], [412, 78, 310, AMBER, 8]]) {
c.each((x, y) => segCov(x, y, [bx, y0, bx, y1], 4)(col, 0.9)); c.each((x, y) => seg(x, y, [bx, y0, bx, y1], w, col, 0.96));
c.each((x, y) => segCov(x, y, [bx, 380, bx, 470], 5)(col, 0.18)); // reflection c.each((x, y) => seg(x, y, [bx, 382, bx, 500], w * 0.9, col, 0.22));
} }
c.each((x, y) => segCov(x, y, [0, 360, W, 360], 1.5)(DIM, 0.5)); // horizon c.each((x, y) => seg(x, y, [0, 362, W, 362], 2, LIGHT, 0.5));
}, },
// Worn portrait — head outline + shoulders + eyes.
'worn-portrait'(c) { 'worn-portrait'(c) {
c.bg([26, 24, 26], [12, 11, 14]); c.bg([44, 42, 47], [13, 12, 16]);
c.each((x, y) => { const d = Math.hypot((x - 240) / 118, (y - 300) / 150); return [LIGHT, (1 - ss(0.97, 1.0, d)) * ss(0.9, 0.95, d) * 0.9]; }); // head ring (oval) c.each((x, y) => [[0, 0, 0], ss(0.72, 1.2, Math.hypot((x - 240) / 300, (y - 320) / 380)) * 0.55]); // vignette
c.each((x, y) => segCov(x, y, [110, 600, 205, 455], 3)(DIM, 0.7)); c.each((x, y) => { const d = Math.hypot((x - 240) / 114, (y - 300) / 148); return [[156, 156, 164], (1 - ss(0.96, 1.03, d)) * 0.55]; }); // face fill
c.each((x, y) => segCov(x, y, [370, 600, 275, 455], 3)(DIM, 0.7)); c.each((x, y) => { const d = Math.hypot((x - 240) / 114, (y - 300) / 148); return [LIGHT, (1 - ss(0.99, 1.03, d)) * ss(0.92, 0.97, d) * 0.9]; }); // rim
c.each((x, y) => [DIM, Math.max(discCov(x, y, 205, 300, 9), discCov(x, y, 275, 300, 9)) * 0.8]); // eyes c.each((x, y) => [DARK, Math.max(disc(x, y, 206, 296, 11), disc(x, y, 274, 296, 11)) * 0.75]); // eyes
c.each((x, y) => segCov(x, y, [240, 300, 240, 350], 2)(DIM, 0.5)); // nose c.each((x, y) => seg(x, y, [240, 302, 240, 356], 3, DARK, 0.5)); // nose
c.each((x, y) => seg(x, y, [104, 600, 206, 452], 4, DIM, 0.75));
c.each((x, y) => seg(x, y, [376, 600, 274, 452], 4, DIM, 0.75));
}, },
// Figure in a doorway — lit rectangle, dark silhouette.
'doorway-figure'(c) { 'doorway-figure'(c) {
c.bg([16, 18, 24], [9, 11, 16]); c.bg([24, 27, 34], [9, 11, 17]);
c.each((x, y) => { const rx = 1 - ss(69, 71, Math.abs(x - 240)); const ry = ss(118, 120, y) * (1 - ss(598, 600, y)); return [[64, 70, 80], rx * ry * 0.85]; }); // lit doorway fill c.each((x, y) => { const rx = 1 - ss(70, 73, Math.abs(x - 240)); const ry = ss(115, 119, y) * (1 - ss(597, 601, y)); return [[158, 166, 180], rx * ry * 0.92]; }); // lit doorway
c.each((x, y) => segCov(x, y, [170, 120, 170, 600], 2.5)(LIGHT, 0.6)); c.each((x, y) => seg(x, y, [167, 117, 167, 600], 3, BRIGHT, 0.75));
c.each((x, y) => segCov(x, y, [310, 120, 310, 600], 2.5)(LIGHT, 0.6)); c.each((x, y) => seg(x, y, [313, 117, 313, 600], 3, BRIGHT, 0.75));
c.each((x, y) => segCov(x, y, [170, 120, 310, 120], 2.5)(LIGHT, 0.6)); c.each((x, y) => seg(x, y, [167, 117, 313, 117], 3, BRIGHT, 0.75));
c.each((x, y) => { const body = (1 - ss(24, 26, Math.abs(x - 240))) * (1 - ss(560, 562, y)) * ss(300, 302, y); return [[10, 12, 16], Math.max(body, discCov(x, y, 240, 300, 24))]; }); // silhouette c.each((x, y) => { const body = (1 - ss(26, 28, Math.abs(x - 240))) * ss(296, 300, y) * (1 - ss(560, 564, y)); return [DARK, Math.max(body, disc(x, y, 240, 294, 26))]; });
}, },
// Pier in the rain — planks, rails, rain streaks.
'rain-pier'(c) { 'rain-pier'(c) {
c.bg([22, 26, 32], [14, 18, 24]); c.bg([36, 42, 51], [19, 25, 33]);
c.each((x, y) => segCov(x, y, [0, 300, W, 300], 1.5)(DIM, 0.4)); // horizon c.each((x, y) => [[54, 63, 76], ss(298, 302, y) * 0.55]); // water region
for (const py of [345, 378, 414, 452]) c.each((x, y) => segCov(x, y, [140, py, 340, py], 2)(DIM, 0.55)); // planks c.each((x, y) => seg(x, y, [0, 300, W, 300], 2.5, LIGHT, 0.6)); // horizon
c.each((x, y) => segCov(x, y, [150, 300, 120, 470], 2)(DIM, 0.5)); // rails converging for (const py of [344, 378, 414, 452]) c.each((x, y) => seg(x, y, [135, py, 345, py], 3, LIGHT, 0.72));
c.each((x, y) => segCov(x, y, [330, 300, 360, 470], 2)(DIM, 0.5)); c.each((x, y) => seg(x, y, [152, 300, 118, 470], 3, LIGHT, 0.62));
c.each((x, y) => seg(x, y, [328, 300, 362, 470], 3, LIGHT, 0.62));
const r = rng(7); const r = rng(7);
for (let i = 0; i < 60; i++) { const x0 = r() * W, y0 = r() * H, len = 22 + r() * 26; c.each((x, y) => segCov(x, y, [x0, y0, x0 - len * 0.32, y0 + len], 0.8)(LIGHT, 0.22)); } for (let i = 0; i < 55; i++) { const x0 = r() * W, y0 = r() * H, len = 26 + r() * 30; c.each((x, y) => seg(x, y, [x0, y0, x0 - len * 0.3, y0 + len], 1.1, LIGHT, 0.28)); }
}, },
}; };