835 lines
28 KiB
JavaScript
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}`);
|