// generate-portraits.js // Procedural B&W NPC portrait generator for SLA Industries // Run: node generate-portraits.js // Output: PNG files + updated index.json in the npc-portrait-pack module 'use strict'; const { createCanvas } = require('canvas'); const fs = require('fs'); const path = require('path'); const OUT_DIR = '/sessions/elegant-peaceful-meitner/mnt/FoundryVTT/Data/modules/npc-portrait-pack/portraits'; const MODULE_ID = 'npc-portrait-pack'; const W = 128, H = 192; // portrait dimensions // ── Seeded RNG (mulberry32) ────────────────────────────────────────────────── function mkRng(seed) { let s = seed >>> 0; return { next() { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }, range(lo, hi) { return Math.floor(this.next() * (hi - lo + 1)) + lo; }, pick(arr) { return arr[Math.floor(this.next() * arr.length)]; }, chance(p) { return this.next() < p; }, }; } // ── Colour palette ─────────────────────────────────────────────────────────── const C = { bg: '#080808', skin: '#c8c8c8', skinMid: '#a8a8a8', skinDark: '#686868', shadow: '#303030', black: '#0a0a0a', white: '#f0f0f0', hilight: '#e8e8e8', outline: '#000000', implant: '#4488aa', implantL: '#88ccee', scar: '#888888', }; // ── Helpers ────────────────────────────────────────────────────────────────── function ellipse(ctx, cx, cy, rx, ry) { ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); } function fillEllipse(ctx, cx, cy, rx, ry, color) { ctx.fillStyle = color; ellipse(ctx, cx, cy, rx, ry); ctx.fill(); } function strokeEllipse(ctx, cx, cy, rx, ry, color, lw = 1.5) { ctx.strokeStyle = color; ctx.lineWidth = lw; ellipse(ctx, cx, cy, rx, ry); ctx.stroke(); } function line(ctx, x1, y1, x2, y2, color, lw = 1.5) { ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } function arcPath(ctx, cx, cy, rx, ry, startAng, endAng) { ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, startAng, endAng); } // Gradient fill for face shading function makeFaceGrad(ctx, cx, cy, rx, ry) { const g = ctx.createRadialGradient(cx - rx * 0.2, cy - ry * 0.25, ry * 0.05, cx, cy, ry * 1.1); g.addColorStop(0, C.hilight); g.addColorStop(0.4, C.skin); g.addColorStop(0.75, C.skinMid); g.addColorStop(1, C.shadow); return g; } // ── Face shape definitions ─────────────────────────────────────────────────── // Each returns {cx, cy, rx, ry, jawW, jawY} — jaw params for chin shape const FACE_SHAPES = [ // 0: oval — standard (rng) => ({ rx: rng.range(32, 42), ry: rng.range(50, 62), jawW: rng.range(22, 30), type: 'oval' }), // 1: square / blocky (rng) => ({ rx: rng.range(38, 48), ry: rng.range(48, 58), jawW: rng.range(32, 42), type: 'square' }), // 2: gaunt / narrow (rng) => ({ rx: rng.range(24, 34), ry: rng.range(56, 68), jawW: rng.range(16, 24), type: 'gaunt' }), // 3: round (rng) => ({ rx: rng.range(36, 46), ry: rng.range(44, 54), jawW: rng.range(28, 38), type: 'round' }), // 4: angular / alien (for ebon/wraith) (rng) => ({ rx: rng.range(26, 36), ry: rng.range(60, 74), jawW: rng.range(14, 22), type: 'angular' }), ]; function drawFace(ctx, rng, shapeIdx, cx, cy) { const s = FACE_SHAPES[shapeIdx](rng); const { rx, ry, type } = s; // Face silhouette ctx.save(); if (type === 'square') { // Rounded rectangle face const x = cx - rx, y = cy - ry, w = rx * 2, h = ry * 2; ctx.beginPath(); ctx.roundRect(x, y, w, h, [rx * 0.35, rx * 0.35, rx * 0.55, rx * 0.55]); } else if (type === 'angular') { // Pointed / angular face ctx.beginPath(); ctx.moveTo(cx - rx, cy - ry * 0.2); ctx.lineTo(cx - rx * 0.8, cy - ry); ctx.lineTo(cx + rx * 0.8, cy - ry); ctx.lineTo(cx + rx, cy - ry * 0.2); ctx.lineTo(cx + rx * 0.5, cy + ry * 0.5); ctx.lineTo(cx, cy + ry); ctx.lineTo(cx - rx * 0.5, cy + ry * 0.5); ctx.closePath(); } else { ellipse(ctx, cx, cy - ry * 0.05, rx, ry); } // Fill with gradient ctx.fillStyle = makeFaceGrad(ctx, cx, cy, rx, ry); ctx.fill(); ctx.strokeStyle = C.outline; ctx.lineWidth = 1.8; ctx.stroke(); ctx.restore(); return { rx, ry, type }; } // ── Hair ───────────────────────────────────────────────────────────────────── const HAIR_STYLES = [ // 0: bald/shaved — just a slight shadow at hairline (ctx, rng, cx, cy, rx, ry) => { ctx.fillStyle = C.shadow; ctx.globalAlpha = 0.25; ctx.beginPath(); ctx.ellipse(cx, cy - ry * 0.7, rx * 1.05, ry * 0.35, 0, Math.PI, 0); ctx.fill(); ctx.globalAlpha = 1; }, // 1: short crop (ctx, rng, cx, cy, rx, ry) => { const h = rng.range(10, 18); ctx.fillStyle = C.black; ctx.beginPath(); ctx.ellipse(cx, cy - ry + h * 0.4, rx * 1.08, h, 0, Math.PI, 0); ctx.fill(); ctx.strokeStyle = C.outline; ctx.lineWidth = 1.5; ctx.stroke(); }, // 2: mohawk (ctx, rng, cx, cy, rx, ry) => { const mw = rng.range(7, 14); const mh = rng.range(22, 40); ctx.fillStyle = C.black; ctx.beginPath(); ctx.moveTo(cx - mw * 0.5, cy - ry + 3); ctx.lineTo(cx - mw * 0.8, cy - ry - mh * 0.6); ctx.lineTo(cx, cy - ry - mh); ctx.lineTo(cx + mw * 0.8, cy - ry - mh * 0.6); ctx.lineTo(cx + mw * 0.5, cy - ry + 3); ctx.closePath(); ctx.fill(); // shaved sides — shadow ctx.fillStyle = C.shadow; ctx.globalAlpha = 0.4; ctx.beginPath(); ctx.ellipse(cx - rx * 0.65, cy - ry * 0.4, rx * 0.38, ry * 0.3, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + rx * 0.65, cy - ry * 0.4, rx * 0.38, ry * 0.3, 0, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }, // 3: undercut / shaved sides + top (ctx, rng, cx, cy, rx, ry) => { const tw = rng.range(rx * 0.45, rx * 0.75); const th = rng.range(12, 22); ctx.fillStyle = C.black; ctx.beginPath(); ctx.ellipse(cx, cy - ry + th * 0.3, tw, th, 0, Math.PI, 0); ctx.fill(); ctx.fillStyle = C.shadow; ctx.globalAlpha = 0.35; ctx.beginPath(); ctx.ellipse(cx - rx * 0.7, cy - ry * 0.35, rx * 0.35, ry * 0.32, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + rx * 0.7, cy - ry * 0.35, rx * 0.35, ry * 0.32, 0, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }, // 4: long hair (frames face, extends below ears) (ctx, rng, cx, cy, rx, ry) => { ctx.fillStyle = C.black; // Top bulk ctx.beginPath(); ctx.ellipse(cx, cy - ry * 0.85, rx * 1.12, ry * 0.3, 0, Math.PI, 0); ctx.fill(); // Side curtains const lx = cx - rx * 1.1, rx2 = cx + rx * 1.1; const topY = cy - ry * 0.55, botY = cy + ry * 0.6; ctx.beginPath(); ctx.moveTo(lx + 4, topY); ctx.lineTo(lx - 4, topY + (botY - topY) * 0.4); ctx.lineTo(lx + 2, botY); ctx.lineTo(lx + 14, botY - 6); ctx.lineTo(lx + 12, topY); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(rx2 - 4, topY); ctx.lineTo(rx2 + 4, topY + (botY - topY) * 0.4); ctx.lineTo(rx2 - 2, botY); ctx.lineTo(rx2 - 14, botY - 6); ctx.lineTo(rx2 - 12, topY); ctx.closePath(); ctx.fill(); }, // 5: hood (ctx, rng, cx, cy, rx, ry) => { const pad = rng.range(8, 18); ctx.fillStyle = C.black; ctx.beginPath(); ctx.ellipse(cx, cy - ry * 0.5, rx + pad, ry * 0.65 + pad, 0, Math.PI, 0); ctx.fill(); // inner hood shadow ring ctx.strokeStyle = C.shadow; ctx.lineWidth = 3; ctx.globalAlpha = 0.6; ctx.beginPath(); ctx.ellipse(cx, cy - ry * 0.3, rx + pad * 0.3, ry * 0.55, 0, Math.PI, 0); ctx.stroke(); ctx.globalAlpha = 1; }, // 6: buzz + beard shadow (ctx, rng, cx, cy, rx, ry) => { ctx.fillStyle = C.black; ctx.beginPath(); ctx.ellipse(cx, cy - ry + 6, rx * 1.05, 12, 0, Math.PI, 0); ctx.fill(); // stubble on jaw ctx.fillStyle = C.shadow; ctx.globalAlpha = 0.5; ctx.beginPath(); ctx.ellipse(cx, cy + ry * 0.55, rx * 0.75, ry * 0.22, 0, 0, Math.PI); ctx.fill(); ctx.globalAlpha = 1; }, // 7: topknot (ctx, rng, cx, cy, rx, ry) => { const tw = rng.range(8, 15); const th = rng.range(16, 28); ctx.fillStyle = C.black; ctx.beginPath(); ctx.ellipse(cx, cy - ry - th * 0.3, tw, th * 0.5, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.moveTo(cx - tw, cy - ry - th * 0.3); ctx.lineTo(cx - tw * 0.6, cy - ry + 4); ctx.lineTo(cx + tw * 0.6, cy - ry + 4); ctx.lineTo(cx + tw, cy - ry - th * 0.3); ctx.closePath(); ctx.fill(); // shaved sides ctx.fillStyle = C.shadow; ctx.globalAlpha = 0.35; ctx.beginPath(); ctx.ellipse(cx - rx * 0.65, cy - ry * 0.4, rx * 0.38, ry * 0.28, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + rx * 0.65, cy - ry * 0.4, rx * 0.38, ry * 0.28, 0, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; }, ]; // ── Eyebrows ───────────────────────────────────────────────────────────────── function drawEyebrows(ctx, rng, cx, eyeY, spread, variant) { const bY = eyeY - rng.range(7, 12); const bW = rng.range(10, 16); const lx = cx - spread, rx = cx + spread; ctx.strokeStyle = C.black; ctx.lineCap = 'round'; if (variant === 'angry') { ctx.lineWidth = 2.5; line(ctx, lx - bW, bY - 3, lx + bW, bY + 2, C.black, 2.5); line(ctx, rx - bW, bY + 2, rx + bW, bY - 3, C.black, 2.5); } else if (variant === 'raised') { ctx.lineWidth = 2; arcPath(ctx, lx, bY + 5, bW, 6, Math.PI * 1.15, Math.PI * 1.85); ctx.stroke(); arcPath(ctx, rx, bY + 5, bW, 6, Math.PI * 1.15, Math.PI * 1.85); ctx.stroke(); } else if (variant === 'flat') { line(ctx, lx - bW, bY, lx + bW, bY, C.black, 2.5); line(ctx, rx - bW, bY, rx + bW, bY, C.black, 2.5); } else { // default arch ctx.lineWidth = 2.2; arcPath(ctx, lx, bY + 4, bW * 0.9, 5, Math.PI * 1.1, Math.PI * 1.9); ctx.stroke(); arcPath(ctx, rx, bY + 4, bW * 0.9, 5, Math.PI * 1.1, Math.PI * 1.9); ctx.stroke(); } } // ── Eyes ───────────────────────────────────────────────────────────────────── function drawEyes(ctx, rng, cx, eyeY, spread, eyeType) { const lx = cx - spread, rx = cx + spread; const ew = rng.range(9, 13), eh = rng.range(5, 8); ctx.save(); if (eyeType === 'normal' || eyeType === 'intense') { const ehMod = eyeType === 'intense' ? eh + 2 : eh; for (const ex of [lx, rx]) { // whites fillEllipse(ctx, ex, eyeY, ew, ehMod, C.white); // iris fillEllipse(ctx, ex, eyeY, ew * 0.55, ehMod * 0.85, C.black); // catchlight fillEllipse(ctx, ex + ew * 0.18, eyeY - ehMod * 0.22, ew * 0.14, ehMod * 0.2, C.white); // outline strokeEllipse(ctx, ex, eyeY, ew, ehMod, C.outline, 1.2); } } else if (eyeType === 'narrow') { for (const ex of [lx, rx]) { fillEllipse(ctx, ex, eyeY, ew * 1.1, eh * 0.45, C.white); fillEllipse(ctx, ex, eyeY, ew * 0.5, eh * 0.35, C.black); strokeEllipse(ctx, ex, eyeY, ew * 1.1, eh * 0.45, C.outline, 1.2); } } else if (eyeType === 'cyborg_one') { // Left = cybernetic, right = normal drawCyborgEye(ctx, rng, lx, eyeY, ew); fillEllipse(ctx, rx, eyeY, ew, eh, C.white); fillEllipse(ctx, rx, eyeY, ew * 0.55, eh * 0.85, C.black); fillEllipse(ctx, rx + ew * 0.18, eyeY - eh * 0.22, ew * 0.14, eh * 0.2, C.white); strokeEllipse(ctx, rx, eyeY, ew, eh, C.outline, 1.2); } else if (eyeType === 'cyborg_both') { drawCyborgEye(ctx, rng, lx, eyeY, ew); drawCyborgEye(ctx, rng, rx, eyeY, ew); } else if (eyeType === 'visor') { // Visor bar across both eyes const vx = cx - spread - ew - 4, vy = eyeY - eh - 2; const vw = spread * 2 + ew * 2 + 8, vh = eh * 2 + 4; ctx.fillStyle = C.shadow; ctx.fillRect(vx, vy, vw, vh); // visor sheen ctx.fillStyle = C.implantL; ctx.globalAlpha = 0.35; ctx.fillRect(vx, vy, vw, vh * 0.35); ctx.globalAlpha = 1; ctx.strokeStyle = C.outline; ctx.lineWidth = 1.5; ctx.strokeRect(vx, vy, vw, vh); line(ctx, vx + 4, eyeY, vx + vw - 4, eyeY, C.implant, 1); } else if (eyeType === 'closed') { for (const ex of [lx, rx]) { line(ctx, ex - ew, eyeY, ex + ew, eyeY, C.black, 2); } } ctx.restore(); } function drawCyborgEye(ctx, rng, ex, ey, ew) { const r = ew * 0.9; fillEllipse(ctx, ex, ey, r, r, C.black); strokeEllipse(ctx, ex, ey, r, r, C.implant, 1.5); // crosshair ctx.strokeStyle = C.implantL; ctx.lineWidth = 0.8; line(ctx, ex - r, ey, ex + r, ey, C.implantL, 0.8); line(ctx, ex, ey - r, ex, ey + r, C.implantL, 0.8); // inner ring strokeEllipse(ctx, ex, ey, r * 0.45, r * 0.45, C.implant, 0.8); // pupil dot fillEllipse(ctx, ex, ey, r * 0.18, r * 0.18, C.implantL); } // ── Nose ───────────────────────────────────────────────────────────────────── function drawNose(ctx, rng, cx, noseY, rx, noseType) { ctx.strokeStyle = C.skinDark; ctx.lineCap = 'round'; const nw = rng.range(5, 10); const nb = noseY - rng.range(10, 18); // bridge top if (noseType === 'straight') { line(ctx, cx, nb, cx, noseY, C.skinDark, 1.2); line(ctx, cx - nw, noseY + 2, cx, noseY, C.skinDark, 1.4); line(ctx, cx + nw, noseY + 2, cx, noseY, C.skinDark, 1.4); } else if (noseType === 'broad') { line(ctx, cx, nb, cx, noseY - 4, C.skinDark, 1.2); arcPath(ctx, cx, noseY - 2, nw * 1.3, 5, Math.PI, 0); ctx.stroke(); } else if (noseType === 'hooked') { ctx.beginPath(); ctx.moveTo(cx + 2, nb); ctx.quadraticCurveTo(cx + 4, (nb + noseY) * 0.5, cx + nw * 0.3, noseY); ctx.strokeStyle = C.skinDark; ctx.lineWidth = 1.3; ctx.stroke(); line(ctx, cx - nw * 0.7, noseY + 1, cx + nw * 0.3, noseY, C.skinDark, 1.3); } else { // button fillEllipse(ctx, cx, noseY, nw * 0.8, 3.5, C.skinDark); line(ctx, cx - nw * 0.5, noseY - 2, cx, noseY - 6, C.skinDark, 1); line(ctx, cx + nw * 0.5, noseY - 2, cx, noseY - 6, C.skinDark, 1); } } // ── Mouth ───────────────────────────────────────────────────────────────────── function drawMouth(ctx, rng, cx, mouthY, rx, mouthType, hasMask) { if (hasMask) return; // covered const mw = rng.range(10, 17); ctx.lineCap = 'round'; if (mouthType === 'thin') { line(ctx, cx - mw, mouthY, cx + mw, mouthY, C.black, 1.8); } else if (mouthType === 'frown') { ctx.strokeStyle = C.black; ctx.lineWidth = 1.8; arcPath(ctx, cx, mouthY - 4, mw, 5, 0.15, Math.PI - 0.15); ctx.stroke(); } else if (mouthType === 'grimace') { line(ctx, cx - mw, mouthY, cx + mw, mouthY, C.black, 2.2); // teeth line ctx.strokeStyle = C.shadow; ctx.lineWidth = 1; for (let i = -mw + 3; i < mw - 3; i += 5) { line(ctx, cx + i, mouthY, cx + i, mouthY + 3, C.shadow, 0.8); } ctx.fillStyle = C.shadow; ctx.globalAlpha = 0.6; ctx.beginPath(); ctx.rect(cx - mw, mouthY, mw * 2, 4); ctx.fill(); ctx.globalAlpha = 1; } else if (mouthType === 'sneer') { ctx.strokeStyle = C.black; ctx.lineWidth = 1.8; arcPath(ctx, cx + mw * 0.3, mouthY + 5, mw * 0.8, 5, Math.PI + 0.2, Math.PI * 2 - 0.1); ctx.stroke(); } else { // slight curve ctx.strokeStyle = C.black; ctx.lineWidth = 1.8; ctx.beginPath(); ctx.moveTo(cx - mw, mouthY); ctx.quadraticCurveTo(cx, mouthY + rng.range(-3, 4), cx + mw, mouthY); ctx.stroke(); } } // ── Accessories ─────────────────────────────────────────────────────────────── function drawScar(ctx, rng, cx, cy, rx, ry) { const type = rng.range(0, 3); ctx.strokeStyle = C.scar; ctx.lineCap = 'round'; if (type === 0) { // diagonal scar across cheek const sx = cx + (rng.chance(0.5) ? 1 : -1) * rng.range(5, 18); const sy = cy - rng.range(5, 15); const ang = rng.range(-40, 40) * Math.PI / 180; const len = rng.range(15, 28); line(ctx, sx, sy, sx + Math.cos(ang) * len, sy + Math.sin(ang) * len, C.scar, 1.2); // stitch marks for (let i = 0.2; i < 0.85; i += 0.18) { const px = sx + Math.cos(ang) * len * i; const py = sy + Math.sin(ang) * len * i; const perp = ang + Math.PI / 2; line(ctx, px + Math.cos(perp) * 2.5, py + Math.sin(perp) * 2.5, px - Math.cos(perp) * 2.5, py - Math.sin(perp) * 2.5, C.scar, 0.8); } } else if (type === 1) { // horizontal scar across nose bridge line(ctx, cx - rng.range(8, 18), cy, cx + rng.range(8, 18), cy, C.scar, 1.5); } else if (type === 2) { // three-claw marks const sy = cy - rng.range(0, 20); const sx = cx - rng.range(10, 25); for (let i = 0; i < 3; i++) { line(ctx, sx + i * 5, sy - 10, sx + i * 5 + 4, sy + 15, C.scar, 1.1); } } else { // burn / patch ctx.fillStyle = C.skinDark; ctx.globalAlpha = 0.5; const bx = cx + rng.range(-20, 20), by = cy + rng.range(-15, 15); fillEllipse(ctx, bx, by, rng.range(6, 12), rng.range(4, 9), C.skinDark); ctx.globalAlpha = 1; } } function drawImplant(ctx, rng, cx, cy, rx, ry, type) { if (type === 'forehead') { const ix = cx + rng.range(-8, 8); const iy = cy - ry * 0.62; fillEllipse(ctx, ix, iy, 5, 4, C.implant); strokeEllipse(ctx, ix, iy, 5, 4, C.implantL, 0.8); fillEllipse(ctx, ix, iy, 2, 1.5, C.implantL); } else if (type === 'cheek') { const side = rng.chance(0.5) ? 1 : -1; const ix = cx + side * rng.range(18, 28); const iy = cy - rng.range(0, 10); // ridge implant ctx.fillStyle = C.implant; ctx.beginPath(); ctx.roundRect(ix - 8, iy - 3, 16, 6, 3); ctx.fill(); for (let s = -4; s <= 4; s += 4) { strokeEllipse(ctx, ix + s, iy, 1.5, 1.5, C.implantL, 0.7); } } else if (type === 'temple') { const side = rng.chance(0.5) ? 1 : -1; const ix = cx + side * rx * 0.85; const iy = cy - ry * 0.35; ctx.fillStyle = C.black; ctx.beginPath(); ctx.roundRect(ix - 6, iy - 5, 12, 10, 2); ctx.fill(); ctx.strokeStyle = C.implant; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(ix - 6, iy - 5, 12, 10, 2); ctx.stroke(); fillEllipse(ctx, ix, iy, 3, 3, C.implant); fillEllipse(ctx, ix, iy, 1.2, 1.2, C.implantL); } else { // ear/neck — done elsewhere const ix = cx + (rng.chance(0.5) ? 1 : -1) * rx * 1.02; const iy = cy + rng.range(-5, 15); ctx.fillStyle = C.implant; ctx.beginPath(); ctx.roundRect(ix - 5, iy - 4, 10, 8, 2); ctx.fill(); } } function drawRebreather(ctx, rng, cx, mouthY, rx) { const mw = rng.range(22, 32), mh = rng.range(10, 16); ctx.fillStyle = C.shadow; ctx.beginPath(); ctx.roundRect(cx - mw, mouthY - 4, mw * 2, mh, 4); ctx.fill(); ctx.strokeStyle = C.black; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.roundRect(cx - mw, mouthY - 4, mw * 2, mh, 4); ctx.stroke(); // filter circles for (let s of [-1, 1]) { const fx = cx + s * mw * 0.55, fy = mouthY + mh * 0.2; fillEllipse(ctx, fx, fy, 5, 4, C.black); strokeEllipse(ctx, fx, fy, 5, 4, C.skinDark, 0.8); fillEllipse(ctx, fx, fy, 2, 1.8, C.skinDark); } // centre vent ctx.strokeStyle = C.skinDark; ctx.lineWidth = 0.8; for (let i = -2; i <= 2; i++) { line(ctx, cx + i * 3, mouthY + 2, cx + i * 3, mouthY + mh - 5, C.skinDark, 0.7); } } function drawNeck(ctx, cx, faceBotY, neckW) { ctx.fillStyle = C.skin; ctx.beginPath(); ctx.moveTo(cx - neckW, faceBotY + 2); ctx.lineTo(cx - neckW * 0.7, H); ctx.lineTo(cx + neckW * 0.7, H); ctx.lineTo(cx + neckW, faceBotY + 2); ctx.closePath(); ctx.fill(); ctx.strokeStyle = C.outline; ctx.lineWidth = 1.5; ctx.stroke(); // collar shadow ctx.fillStyle = C.shadow; ctx.globalAlpha = 0.35; ctx.beginPath(); ctx.moveTo(cx - neckW * 0.7, H - 8); ctx.lineTo(cx + neckW * 0.7, H - 8); ctx.lineTo(cx + neckW * 1.0, H); ctx.lineTo(cx - neckW * 1.0, H); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1; } function drawClothing(ctx, rng, cx, faceBot, clothingType) { ctx.fillStyle = clothingType === 'armor' ? '#282828' : clothingType === 'suit' ? '#1a1a1a' : C.shadow; if (clothingType === 'armor') { // Shoulder armour plates ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(0, faceBot + 28); ctx.lineTo(cx * 0.4, faceBot + 12); ctx.lineTo(cx * 0.9, faceBot + 22); ctx.lineTo(cx * 0.85, H); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(W, H); ctx.lineTo(W, faceBot + 28); ctx.lineTo(W - cx * 0.4, faceBot + 12); ctx.lineTo(W - cx * 0.9, faceBot + 22); ctx.lineTo(W - cx * 0.85, H); ctx.closePath(); ctx.fill(); ctx.strokeStyle = C.skinDark; ctx.lineWidth = 1; ctx.stroke(); } else if (clothingType === 'suit') { ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(cx * 0.3, faceBot + 15); ctx.lineTo(cx, faceBot + 25); ctx.lineTo(W - cx * 0.3, faceBot + 15); ctx.lineTo(W, H); ctx.closePath(); ctx.fill(); // tie ctx.fillStyle = C.black; ctx.beginPath(); ctx.moveTo(cx - 4, faceBot + 22); ctx.lineTo(cx + 4, faceBot + 22); ctx.lineTo(cx + 6, faceBot + 50); ctx.lineTo(cx, faceBot + 58); ctx.lineTo(cx - 6, faceBot + 50); ctx.closePath(); ctx.fill(); } else { // jacket / casual ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(cx * 0.2, faceBot + 20); ctx.lineTo(cx, faceBot + 28); ctx.lineTo(W - cx * 0.2, faceBot + 20); ctx.lineTo(W, H); ctx.closePath(); ctx.fill(); } } function drawVignette(ctx) { const g = ctx.createRadialGradient(W / 2, H / 2, H * 0.25, W / 2, H / 2, H * 0.85); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(0.7, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.7)'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); } // ── Category-specific modifiers ─────────────────────────────────────────────── const CATEGORY_PARAMS = { human: { shapeIdx: [0, 1, 2, 3], hairStyles:[0,1,2,3,4,5,6,7], eyeTypes: ['normal','normal','narrow','intense','cyborg_one','visor'], clothing: ['jacket','armor','suit','jacket'], }, ebon: { shapeIdx: [4, 4, 2, 4], // angular elongated hairStyles:[0,0,3,5,7], // often bald or shaved eyeTypes: ['intense','cyborg_one','cyborg_both','normal','visor'], clothing: ['jacket','armor'], skinTone: '#b8b8b8', }, frother: { shapeIdx: [1, 1, 3], // square/blocky hairStyles:[2, 3, 6, 7], // mohawk, undercut eyeTypes: ['intense','normal','cyborg_one'], clothing: ['armor','armor','jacket'], }, shaktar: { shapeIdx: [4, 2, 4], hairStyles:[0, 0, 5], // bald or hooded eyeTypes: ['intense','narrow','cyborg_both'], clothing: ['armor','jacket'], skinTone: '#909090', }, waister: { shapeIdx: [2, 4], // gaunt/angular hairStyles:[0, 1, 5], eyeTypes: ['intense','narrow'], clothing: ['jacket'], skinTone: '#b0b0b0', }, wraith: { shapeIdx: [4, 2, 4], hairStyles:[5, 5, 0, 7], // hoods, bald eyeTypes: ['visor','cyborg_both','narrow','closed'], clothing: ['jacket','armor'], skinTone: '#d0d0d0', }, threat: { shapeIdx: [1, 2, 3, 1], hairStyles:[0, 2, 6, 3], eyeTypes: ['intense','normal','cyborg_one'], clothing: ['jacket','armor'], }, }; // ── Main portrait renderer ─────────────────────────────────────────────────── function renderPortrait(seed, category) { const canvas = createCanvas(W, H); const ctx = canvas.getContext('2d'); const rng = mkRng(seed); const params = CATEGORY_PARAMS[category] ?? CATEGORY_PARAMS.human; // ── Background ctx.fillStyle = C.bg; ctx.fillRect(0, 0, W, H); // ── Layout constants const cx = W / 2; const shapeIdx = rng.pick(params.shapeIdx); const tempShape = FACE_SHAPES[shapeIdx](mkRng(seed + 1)); const { rx, ry } = tempShape; const faceY = Math.round(H * 0.46); const faceBotY = faceY + ry; const neckW = Math.round(rx * 0.52); // Skin tone override if (params.skinTone) { C.skin = params.skinTone; // Rebuild gradient colors C.skinMid = params.skinTone; C.skinDark = '#666'; C.hilight = '#ddd'; } else { C.skin = '#c8c8c8'; C.skinMid = '#a8a8a8'; C.skinDark = '#686868'; C.hilight = '#e8e8e8'; } // ── Neck + clothing (behind face) drawNeck(ctx, cx, faceBotY, neckW); drawClothing(ctx, rng, cx, faceBotY, rng.pick(params.clothing)); // ── Hair (some styles go behind face) const hairStyleIdx = rng.pick(params.hairStyles); HAIR_STYLES[hairStyleIdx](ctx, rng, cx, faceY, rx, ry); // ── Face const face = drawFace(ctx, rng, shapeIdx, cx, faceY); // ── Feature positions const eyeSpread = Math.round(rx * 0.48); const eyeY = faceY - ry * 0.18; const noseY = faceY + ry * 0.22; const mouthY = faceY + ry * 0.52; // ── Eyebrows const browVariant = rng.pick(['default','angry','raised','flat']); drawEyebrows(ctx, rng, cx, eyeY, eyeSpread, browVariant); // ── Eyes const eyeType = rng.pick(params.eyeTypes); drawEyes(ctx, rng, cx, eyeY, eyeSpread, eyeType); // ── Nose const noseType = rng.pick(['straight','broad','hooked','button']); drawNose(ctx, rng, cx, noseY, rx, noseType); // ── Rebreather / mask (30% chance; replaces mouth if present) const hasMask = rng.chance(0.28) && eyeType !== 'visor'; if (hasMask) { drawRebreather(ctx, rng, cx, mouthY - 4, rx); } else { const mouthType = rng.pick(['thin','frown','grimace','sneer','curve']); drawMouth(ctx, rng, cx, mouthY, rx, mouthType, false); } // ── Accessories if (rng.chance(0.35)) drawScar(ctx, rng, cx, faceY, rx, ry); if (rng.chance(0.3) && eyeType !== 'cyborg_both') { drawImplant(ctx, rng, cx, faceY, rx, ry, rng.pick(['forehead','cheek','temple','ear'])); } // ── Vignette drawVignette(ctx); return canvas; } // ── Generation batch config ────────────────────────────────────────────────── const BATCHES = [ { prefix: 'proc_human', category: 'human', count: 50 }, { prefix: 'proc_ebon', category: 'ebon', count: 30 }, { prefix: 'proc_frother', category: 'frother', count: 25 }, { prefix: 'proc_shaktar', category: 'shaktar', count: 20 }, { prefix: 'proc_waister', category: 'waister', count: 20 }, { prefix: 'proc_wraith', category: 'wraith', count: 25 }, { prefix: 'proc_threat', category: 'threat', count: 30 }, ]; // ── Run ─────────────────────────────────────────────────────────────────────── let total = 0; let baseSeed = 0x5EED0000; for (const batch of BATCHES) { for (let i = 1; i <= batch.count; i++) { const filename = `${batch.prefix}_${String(i).padStart(2,'0')}.png`; const filepath = path.join(OUT_DIR, filename); const canvas = renderPortrait(baseSeed + total, batch.category); const buf = canvas.toBuffer('image/png'); fs.writeFileSync(filepath, buf); total++; } process.stdout.write(` ✓ ${batch.prefix} (${batch.count})\n`); } console.log(`\nGenerated ${total} procedural portraits → ${OUT_DIR}`);