diff --git a/public/art/detective-alley.png b/public/art/detective-alley.png index f3af5cf..9251d79 100644 Binary files a/public/art/detective-alley.png and b/public/art/detective-alley.png differ diff --git a/public/art/doorway-figure.png b/public/art/doorway-figure.png index a809783..c6f0501 100644 Binary files a/public/art/doorway-figure.png and b/public/art/doorway-figure.png differ diff --git a/public/art/neon-street.png b/public/art/neon-street.png index 942982f..b30c5e7 100644 Binary files a/public/art/neon-street.png and b/public/art/neon-street.png differ diff --git a/public/art/rain-pier.png b/public/art/rain-pier.png index a14650d..5850033 100644 Binary files a/public/art/rain-pier.png and b/public/art/rain-pier.png differ diff --git a/public/art/worn-portrait.png b/public/art/worn-portrait.png index 4c2bb7a..97025f2 100644 Binary files a/public/art/worn-portrait.png and b/public/art/worn-portrait.png differ diff --git a/tools/gen-art-placeholders.mjs b/tools/gen-art-placeholders.mjs index cc53fae..8b3a94f 100644 --- a/tools/gen-art-placeholders.mjs +++ b/tools/gen-art-placeholders.mjs @@ -1,6 +1,6 @@ // 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, -// atmospheric charcoal motifs. Swap for real pencil art anytime. +// src/art/manifest.js). Pure Node — hand-rolled PNG encoder + analytic AA. +// Bold charcoal motifs with large tonal regions so they read at thumbnail size. // node tools/gen-art-placeholders.mjs import { deflateSync } from 'node:zlib'; 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 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 LIGHT = [206, 214, 222], DIM = [150, 160, 172], BRIGHT = [232, 238, 244], AMBER = [232, 200, 150]; +const AA = 1.2; +const LIGHT = [222, 230, 238], DIM = [178, 188, 202], BRIGHT = [242, 246, 252], AMBER = [242, 208, 150], DARK = [9, 11, 15]; function canvas() { const buf = Buffer.alloc(W * H * 4); - const api = { + return { 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; } } }, - 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); } }, + 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 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 discCov = (px, py, cx, cy, r) => 1 - ss(r - AA, r + AA, Math.hypot(px - cx, py - cy)); +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 disc = (px, py, cx, cy, r) => 1 - ss(r - AA, r + AA, Math.hypot(px - cx, py - cy)); const scenes = { - // Detective in alley — converging walls, backlit figure. 'detective-alley'(c) { - c.bg([24, 27, 32], [12, 15, 20]); - c.each((x, y) => { - let a = 0, col = LIGHT; - const halo = Math.max(0, 1 - Math.hypot(x - 240, y - 500) / 150) * 0.4; // backlight - a = halo; col = [90, 96, 104]; - return [col, a]; - }); - 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)]; - }); + c.bg([44, 49, 58], [15, 19, 25]); + 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 + c.each((x, y) => seg(x, y, [28, H, 236, 168], 3.5, LIGHT, 0.9)); + c.each((x, y) => seg(x, y, [452, H, 244, 168], 3.5, LIGHT, 0.9)); + c.each((x, y) => seg(x, y, [78, 566, 402, 566], 3, LIGHT, 0.75)); + 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]; }); }, - // Neon street — vertical light bars + reflections. 'neon-street'(c) { - c.bg([20, 22, 30], [10, 12, 18]); - const bars = [[80, 70, 300, LIGHT], [150, 110, 330, AMBER], [300, 60, 280, BRIGHT], [372, 130, 340, LIGHT], [420, 90, 300, AMBER]]; - for (const [bx, y0, y1, col] of bars) { - c.each((x, y) => segCov(x, y, [bx, y0, bx, y1], 4)(col, 0.9)); - c.each((x, y) => segCov(x, y, [bx, 380, bx, 470], 5)(col, 0.18)); // reflection + c.bg([34, 37, 48], [11, 13, 20]); + 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, 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) => seg(x, y, [bx, y0, bx, y1], w, col, 0.96)); + 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) { - c.bg([26, 24, 26], [12, 11, 14]); - 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) => segCov(x, y, [110, 600, 205, 455], 3)(DIM, 0.7)); - c.each((x, y) => segCov(x, y, [370, 600, 275, 455], 3)(DIM, 0.7)); - 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) => segCov(x, y, [240, 300, 240, 350], 2)(DIM, 0.5)); // nose + c.bg([44, 42, 47], [13, 12, 16]); + 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) => { 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) => { 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) => [DARK, Math.max(disc(x, y, 206, 296, 11), disc(x, y, 274, 296, 11)) * 0.75]); // eyes + 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) { - c.bg([16, 18, 24], [9, 11, 16]); - 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) => segCov(x, y, [170, 120, 170, 600], 2.5)(LIGHT, 0.6)); - c.each((x, y) => segCov(x, y, [310, 120, 310, 600], 2.5)(LIGHT, 0.6)); - c.each((x, y) => segCov(x, y, [170, 120, 310, 120], 2.5)(LIGHT, 0.6)); - 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.bg([24, 27, 34], [9, 11, 17]); + 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) => seg(x, y, [167, 117, 167, 600], 3, BRIGHT, 0.75)); + c.each((x, y) => seg(x, y, [313, 117, 313, 600], 3, BRIGHT, 0.75)); + c.each((x, y) => seg(x, y, [167, 117, 313, 117], 3, BRIGHT, 0.75)); + 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) { - c.bg([22, 26, 32], [14, 18, 24]); - c.each((x, y) => segCov(x, y, [0, 300, W, 300], 1.5)(DIM, 0.4)); // horizon - 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) => segCov(x, y, [150, 300, 120, 470], 2)(DIM, 0.5)); // rails converging - c.each((x, y) => segCov(x, y, [330, 300, 360, 470], 2)(DIM, 0.5)); + c.bg([36, 42, 51], [19, 25, 33]); + c.each((x, y) => [[54, 63, 76], ss(298, 302, y) * 0.55]); // water region + c.each((x, y) => seg(x, y, [0, 300, W, 300], 2.5, LIGHT, 0.6)); // horizon + 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) => 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); - 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)); } }, };