Files
npc-portrait-pack/scripts/generate-portraits.js
T
2026-03-31 17:00:15 +01:00

835 lines
28 KiB
JavaScript

// 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}`);