chore: initial deploy — SLA Industries Foundry VTT
Co-authored-by: slaguru666 <timevans666@gmail.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// build-index.mjs — Node.js utility script
|
||||
// Scans portraits/ and regenerates portraits/index.json
|
||||
// Run with: node scripts/build-index.mjs
|
||||
// (This script is NOT loaded by Foundry — it's a dev tool only.)
|
||||
|
||||
import { readdir, writeFile } from 'fs/promises';
|
||||
import { join, extname, basename, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MODULE_DIR = join(__dirname, '..');
|
||||
const PORTRAITS = join(MODULE_DIR, 'portraits');
|
||||
const MODULE_ID = 'npc-portrait-pack';
|
||||
|
||||
// ── Category classification ────────────────────────────────────────────
|
||||
function classify(filename) {
|
||||
const name = basename(filename, '.png');
|
||||
|
||||
// Named NPC: npc_NNNN_description
|
||||
if (/^npc_\d+_/.test(name)) {
|
||||
const parts = name.split('_');
|
||||
const tags = [];
|
||||
let gender = null;
|
||||
for (const p of parts.slice(2)) {
|
||||
if (p === 'male' || p === 'female') { gender = p; tags.unshift(p); }
|
||||
else if (p === 'elderly') { tags.push('elderly'); }
|
||||
else { tags.push(p); }
|
||||
}
|
||||
return { category: 'named_npc', race: 'human', gender, tags };
|
||||
}
|
||||
|
||||
// Race + gender: race_gender_NN
|
||||
const rg = name.match(/^(human|ebon|frother|shaktar|stormer|waister|wraith)_(male|female)_\d+$/);
|
||||
if (rg) {
|
||||
const [, race, gender] = rg;
|
||||
return { category: race, race, gender, tags: [gender] };
|
||||
}
|
||||
|
||||
if (/^creature_\d+$/.test(name)) return { category: 'creature', race: 'creature', gender: null, tags: ['creature'] };
|
||||
if (/^mutant_\d+$/.test(name)) return { category: 'creature', race: 'mutant', gender: null, tags: ['mutant','creature'] };
|
||||
if (/^serial_killer_\d+$/.test(name))return { category: 'serial_killer', race: 'human', gender: null, tags: ['serial_killer','threat'] };
|
||||
if (/^portrait_\d+$/.test(name)) return { category: 'generic', race: 'human', gender: null, tags: [] };
|
||||
|
||||
return { category: 'generic', race: 'unknown', gender: null, tags: [] };
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────
|
||||
async function buildIndex() {
|
||||
const files = await readdir(PORTRAITS);
|
||||
const pngFiles = files.filter(f => extname(f).toLowerCase() === '.png').sort();
|
||||
|
||||
const portraits = pngFiles.map(filename => {
|
||||
const id = basename(filename, '.png');
|
||||
const { category, race, gender, tags } = classify(filename);
|
||||
return {
|
||||
id,
|
||||
path: `modules/${MODULE_ID}/portraits/${filename}`,
|
||||
category,
|
||||
race,
|
||||
gender,
|
||||
tags,
|
||||
};
|
||||
});
|
||||
|
||||
portraits.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const index = { version: '1.0.0', count: portraits.length, portraits };
|
||||
const outputPath = join(PORTRAITS, 'index.json');
|
||||
await writeFile(outputPath, JSON.stringify(index, null, 2), 'utf-8');
|
||||
|
||||
// Summary
|
||||
const cats = portraits.reduce((acc, p) => {
|
||||
acc[p.category] = (acc[p.category] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
console.log(`✓ Built index: ${portraits.length} portraits → ${outputPath}`);
|
||||
console.log('\nBy category:');
|
||||
for (const [cat, count] of Object.entries(cats).sort()) {
|
||||
console.log(` ${cat.padEnd(15)} ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
buildIndex().catch(err => { console.error('Error:', err); process.exit(1); });
|
||||
@@ -0,0 +1,834 @@
|
||||
// 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}`);
|
||||
@@ -0,0 +1,998 @@
|
||||
// npc-generator.mjs — SLA Industries NPC Generator
|
||||
// Builds ready-to-use NPC actors with stats, skills, gear, portrait, and background.
|
||||
|
||||
import { MODULE_ID } from './settings.mjs';
|
||||
|
||||
// ============================================================================
|
||||
// Name tables — 485 first names + 500 surnames from SLA Industries Name Reference
|
||||
// ============================================================================
|
||||
|
||||
// 485 SLA Industries first names (used across all human-presenting species)
|
||||
const FIRST_NAMES = [
|
||||
'Axel','Blade','Cain','Drex','Echo','Flux','Ghost','Havoc',
|
||||
'Irek','Judas','Knox','Lash','Mace','Nox','Onyx','Punk',
|
||||
'Quill','Raze','Scar','Thorn','Ulric','Vex','Wraith','Xane',
|
||||
'Yael','Zane','Arc','Brix','Colt','Dusk','Edge','Fang',
|
||||
'Grim','Haze','Itch','Jolt','Kron','Lurk','Mire','Nail',
|
||||
'Omen','Pyre','Quin','Rust','Slag','Taunt','Urge','Void',
|
||||
'Wrath','Xerx','Yank','Zero','Ash','Burn','Crag','Dread',
|
||||
'Elx','Fell','Gore','Hook','Inch','Jab','Kink','Leech',
|
||||
'Mort','Noose','Orge','Pox','Quake','Rift','Shiv','Taint',
|
||||
'Ugly','Vain','Weld','Xray','Yore','Zeal','Abis','Bale',
|
||||
'Crux','Dirge','Effi','Funk','Gust','Hilt','Ivor','Jest',
|
||||
'Kade','Lore','Munk','Nave','Odis','Prod','Qade','Rend',
|
||||
'Smog','Tusk','Ulna','Vark','Wisp','Xell','Yost','Zona',
|
||||
'Alek','Bren','Cass','Dace','Erin','Fenn','Gale','Holt',
|
||||
'Isak','Jace','Kael','Lind','Marc','Neal','Orin','Penn',
|
||||
'Rand','Sage','Tane','Ulan','Venn','Ward','Xord','Yule',
|
||||
'Zack','Amos','Boyd','Cole','Dale','Earl','Ford','Glen',
|
||||
'Hank','Ivan','Joel','Kent','Lane','Mark','Neil','Owen',
|
||||
'Paul','Reed','Seth','Todd','Umar','Wade','York','Zion',
|
||||
'Arno','Bram','Cruz','Deon','Erik','Finn','Greg','Hugo',
|
||||
'Ilan','Jude','Karl','Lars','Milo','Nils','Otto','Petr',
|
||||
'Rolf','Sven','Theo','Ulf','Vito','Wulf','Xavi','Yuri',
|
||||
'Zeno','Adan','Blas','Ciro','Dain','Egon','Falk','Gabe',
|
||||
'Hein','Ingo','Jaro','Knut','Leon','Maik','Niko','Olaf',
|
||||
'Pavo','Rudi','Simo','Timo','Uros','Vojo','Wolf','Xeno',
|
||||
'Yann','Zaid','Alec','Beau','Cade','Dean','Evan','Frey',
|
||||
'Gage','Hale','Iain','Jake','Kane','Luke','Matt','Nash',
|
||||
'Omar','Pike','Raja','Shay','Trev','Vane','Xion','Yago',
|
||||
'Zeke','Abel','Bink','Clex','Drax','Elen','Frex','Gron',
|
||||
'Hexx','Ilex','Jinn','Klex','Lorx','Nrex','Oxel','Prex',
|
||||
'Trex','Alva','Brex','Ceyx','Drey','Elox','Frox','Grex',
|
||||
'Ilox','Jrex','Krex','Lrex','Mrex','Nrox','Orex','Prox',
|
||||
'Trox','Abol','Brek','Cren','Drek','Erek','Frek','Grek',
|
||||
'Hrek','Trek','Krek','Lrek','Mrek','Nrek','Orek','Prek',
|
||||
'Srek','Urek','Vrek','Wrek','Xrek','Yrek','Zrek','Kata',
|
||||
'Sora','Mira','Lyra','Vera','Nora','Dara','Zara','Lara',
|
||||
'Kira','Yara','Tara','Sara','Cara','Fara','Gara','Hara',
|
||||
'Jara','Alix','Blix','Clix','Elix','Flix','Glix','Hlix',
|
||||
'Krix','Lrix','Mrix','Nrix','Orix','Prix','Trix','Urix',
|
||||
'Vrix','Wrix','Xrix','Yrix','Zrix','Nova','Rena','Sela',
|
||||
'Tela','Vela','Xela','Yela','Zela','Aela','Bela','Cela',
|
||||
'Dela','Fela','Gela','Hela','Iela','Jela','Kela','Lela',
|
||||
'Mela','Nela','Pela','Rela','Axia','Bria','Cria','Dria',
|
||||
'Eria','Fria','Gria','Iria','Jria','Kria','Lria','Mria',
|
||||
'Nria','Oria','Pria','Sria','Tria','Uria','Vria','Wria',
|
||||
'Xria','Yria','Zria','Alox','Blox','Clox','Elox','Flox',
|
||||
'Glox','Ilox','Jlox','Klox','Mlox','Nlox','Olox','Plox',
|
||||
'Rlox','Slox','Tlox','Ulox','Vlox','Wlox','Xlox','Ylox',
|
||||
'Zlox','Amox','Emox','Fmox','Gmox','Hmox','Imox','Jmox',
|
||||
'Kmox','Lmox','Nmox','Omox','Pmox','Rmox','Smox','Tmox',
|
||||
'Umox','Vmox','Wmox','Xmox','Ymox','Zmox','Anox','Bnox',
|
||||
'Cnox','Dnox','Enox','Fnox','Gnox','Hnox','Inox','Jnox',
|
||||
'Lnox','Mnox','Nnox','Onox','Pnox','Qnox','Rnox','Snox',
|
||||
'Tnox','Unox','Vnox','Wnox','Xnox','Ynox','Znox','Raxx',
|
||||
'Vexx','Zoxx','Paxx','Daxx','Taxx',
|
||||
];
|
||||
|
||||
// 500 SLA-flavoured dystopian surnames
|
||||
const SURNAMES = [
|
||||
'Ashcroft','Blackmore','Carnage','Deathridge','Eviscer','Flayer','Gravemore','Hellgate',
|
||||
'Ironside','Junkyard','Killmore','Lacemore','Meatridge','Noosemore','Overkill','Painmore',
|
||||
'Quickdeath','Razormore','Slaughton','Tombmore','Undead','Vilemore','Woundmore','Xenocide',
|
||||
'Yarmore','Zeromore','Abattoir','Bloodmore','Crushmore','Darkmore','Edgemore','Fleshmore',
|
||||
'Gutmore','Hackmore','Icepick','Jawmore','Knifemore','Lungmore','Maggotmore','Nailmore',
|
||||
'Orbmore','Pilemore','Quarrymore','Rippmore','Slagmore','Tearmore','Ulcermore','Venommore',
|
||||
'Waxmore','Xoremore','Yardmore','Zoremore','Akemore','Balemore','Coldmore','Dirtmore',
|
||||
'Endmore','Foulmore','Grimmore','Holemore','Inkmore','Jailmore','Ketmore','Loremore',
|
||||
'Moldmore','Numbmore','Odmore','Pitmore','Quemore','Rotmore','Snagmore','Tormore',
|
||||
'Ulmore','Vastmore','Wiremore','Xetmore','Yoremore','Zetmore','Aldrich','Burnwick',
|
||||
'Cruelton','Darkwood','Edgerton','Frostmire','Greystone','Hollowmore','Ironwood','Jadwick',
|
||||
'Killwick','Lostwick','Mirewick','Nightwick','Oddwick','Painwick','Quailwick','Rustwick',
|
||||
'Skelwick','Thornwick','Umwick','Vilewick','Wormwick','Xawick','Yellwick','Zombwick',
|
||||
'Ashwick','Bladewick','Corewick','Deadwick','Eelwick','Fluxwick','Grimewick','Hatewick',
|
||||
'Icewick','Jawwick','Knoxwick','Lashwick','Morwick','Nailwick','Ogrewick','Poxwick',
|
||||
'Quakwick','Rifwick','Slumwick','Tombwick','Urgwick','Vexwick','Waxwick','Xerwick',
|
||||
'Yardwick','Zorewick','Abwick','Brixwick','Cruxwick','Dregwick','Exwick','Forwick',
|
||||
'Gustwick','Hiltwick','Ivorwick','Jestwick','Kadewick','Lorewick','Munkwick','Navewick',
|
||||
'Odinwick','Prodwick','Quadewick','Rendwick','Smogwick','Tuskwick','Ulnawick','Varkwick',
|
||||
'Wispwick','Xellwick','Yostwick','Zonawick','Aldmore','Brenmore','Cassmore','Dacemore',
|
||||
'Erinmore','Fennmore','Galemore','Holtmore','Isakmore','Jacemore','Kaelmore','Lindmore',
|
||||
'Marcmore','Nealmore','Orinmore','Pennmore','Randmore','Sagemore','Tanemore','Ulanmore',
|
||||
'Vennmore','Wardmore','Xordmore','Yulmore','Zackmore','Amosmore','Boydmore','Colemore',
|
||||
'Dalemore','Earlmore','Fordmore','Glenmore','Hankmore','Ivanmore','Joelmore','Kentmore',
|
||||
'Lanemore','Markmore','Neilmore','Owenmore','Paulmore','Reedmore','Sethmore','Toddmore',
|
||||
'Umarmore','Wademore','Yorkmore','Zionmore','Arnomore','Brammore','Cruzmore','Deonmore',
|
||||
'Erikmore','Finnmore','Gregmore','Hugomore','Ilanmore','Judemore','Karlmore','Larsmore',
|
||||
'Milomore','Nilsmore','Ottomore','Petrmore','Rolfmore','Svenmore','Theomore','Ulfmore',
|
||||
'Vitomore','Wulfmore','Xvimore','Yuriore','Zenomore','Adanmore','Blasmore','Ciromore',
|
||||
'Dainmore','Egonmore','Falkmore','Gabemore','Heinmore','Ingomore','Jaromore','Knutmore',
|
||||
'Leonmore','Maikmore','Nikomore','Olafmore','Pavomore','Rudimore','Simomore','Timomore',
|
||||
'Urosmore','Vojomore','Wolfmore','Xenomore','Yannmore','Zaidmore','Alecmore','Beaumore',
|
||||
'Cademore','Deanmore','Evanmore','Freymore','Gagemore','Halemore','Iainmore','Jakemore',
|
||||
'Kanemore','Lukemore','Mattmore','Nashmore','Omarmore','Pikemore','Rajamore','Shaywick',
|
||||
'Trevwick','Ulanwick','Vanewick','Wadewick','Xionwick','Yagowick','Zekewick','Abelwick',
|
||||
'Binkwick','Clexwick','Draxwick','Elenwick','Frexwick','Gronwick','Hexwick','Ilexwick',
|
||||
'Jinnwick','Klexwick','Lorxwick','Mnexwick','Nrexwick','Oxelwick','Prexwick','Qluxwick',
|
||||
'Rexwick','Srexwick','Trexwick','Urexwick','Vrexwick','Wrexwick','Xrexwick','Yrexwick',
|
||||
'Zrexwick','Alvwick','Brixton','Cexton','Drexton','Eloxton','Froxton','Grexton',
|
||||
'Hrexton','Iloxton','Jrexton','Krexton','Lrexton','Mrexton','Nroxton','Orexton',
|
||||
'Proxton','Qrexton','Rroxton','Sroxton','Troxton','Uroxton','Vroxton','Wroxton',
|
||||
'Xroxton','Yroxton','Zroxton','Abolt','Brekon','Crendon','Drekon','Erekon',
|
||||
'Frekon','Grekon','Hrekon','Irekon','Jrekon','Krekon','Lrekon','Mrekon',
|
||||
'Nrekon','Orekon','Prekon','Qrekon','Rrekon','Srekon','Trekon','Urekon',
|
||||
'Vrekon','Wrekon','Xrekon','Yrekon','Zrekon','Katawick','Sorawick','Mirawick',
|
||||
'Lyrawick','Verawick','Norawick','Darawick','Zarawick','Larawick','Kirawick','Yarawick',
|
||||
'Tarawick','Sarawick','Parawick','Barawick','Carawick','Farawick','Garawick','Harawick',
|
||||
'Jarawick','Alixmore','Blixmore','Clixmore','Dlixmore','Elixmore','Flixmore','Glixmore',
|
||||
'Hlixmore','Ilixmore','Jlixmore','Krixmore','Lrixmore','Mrixmore','Nrixmore','Orixmore',
|
||||
'Prixmore','Qrixmore','Rrixmore','Srixmore','Trixmore','Urixmore','Vrixmore','Wrixmore',
|
||||
'Xrixmore','Yrixmore','Zrixmore','Novawick','Renawick','Selawick','Telawick','Velawick',
|
||||
'Welawick','Xelawick','Yelawick','Zelawick','Aelawick','Belawick','Celawick','Delawick',
|
||||
'Eelawick','Felawick','Gelawick','Helawick','Ielawick','Jelawick','Kelawick','Lelawick',
|
||||
'Melawick','Nelawick','Oelawick','Pelawick','Qelawick','Relawick','Briawick','Criawick',
|
||||
'Driawick','Eriawick','Friawick','Griawick','Hriawick','Iriawick','Jriawick','Kriawick',
|
||||
'Lriawick','Mriawick','Nriawick','Oriawick','Priawick','Sriawick','Triawick','Uriawick',
|
||||
'Vriawick','Wriawick','Xriawick','Yriawick','Zriawick','Aloxwick','Bloxwick','Cloxwick',
|
||||
'Eloxwick','Floxwick','Gloxwick','Hloxwick','Iloxwick','Jloxwick','Kloxwick','Mloxwick',
|
||||
'Nloxwick','Oloxwick','Ploxwick','Rloxwick','Sloxwick','Tloxwick','Uloxwick','Vloxwick',
|
||||
'Wloxwick','Xloxwick','Yloxwick','Zloxwick','Amoxwick','Cmoxwick','Dmoxwick','Emoxwick',
|
||||
'Fmoxwick','Gmoxwick','Hmoxwick','Imoxwick','Jmoxwick','Kmoxwick','Lmoxwick','Mmoxwick',
|
||||
'Nmoxwick','Omoxwick',
|
||||
];
|
||||
|
||||
// 250 serial killer / threat identities for the Mutant / Serial Killer archetype
|
||||
const SERIAL_KILLER_NAMES = [
|
||||
'Jack The Dissector','The Canker Street Carver','Mother Carnage','The Pale Butcher',
|
||||
'Severin Voss','The Apartment Bleeder','Grimshaw The Peeler','Sister Mortis',
|
||||
'The 700 Block Ripper','Creed The Hollow','Magda Filch','The Downpipe Strangler',
|
||||
'Elden Rake','The Meatpacking Saint','Nurse Lacewick','The Sump Killer',
|
||||
'Doktor Vein','The Canal Street Feeder','Abner Retch','The Sleeping God',
|
||||
'Kira The Opener','The Soft Tissue Sculptor','Father Gristle','The Smiling Patient',
|
||||
'Oswin Craw','The Tooth Collector','Lady Carnifax','The Quiet Orderly',
|
||||
'Brother Splint','The Ceiling Crawler','Yelena Dross','The Lunch Hour Killer',
|
||||
'Morten Peel','The Chimney Bather','Sera The Hollow','The Duct Work Ghoul',
|
||||
'Aldric Vane','The Body Farm Pilgrim','Sister Phlegm','The Corridor Creeper',
|
||||
'Thane Grippe','The Overflow Wraith','Magda Vesper','The Two-Floor Terror',
|
||||
'Reko The Saint','The Basement Surgeon','Brother Ache','The Office Block Phantom',
|
||||
'Drace Mortum','The Substation Bleeder','Ilsa Craven','The Stairwell Whisper',
|
||||
'Venan Pitch','The Loading Bay Killer','Mother Osse','The Transfer Tube Slasher',
|
||||
'Kyne Reverie','The Uptown Dissector','Father Rend','The Walkway Predator',
|
||||
'Shrake The Cutter','The Overflow District Flayer','Madra Vile','The Underpass Surgeon',
|
||||
'Solen Craw','The Airlock Garrotter','Sister Bile','The Power Cell Butcher',
|
||||
'Hektor Gnash','The Freight Elevator Killer','Brother Worm','The Recycling Centre Ripper',
|
||||
'Treva Mordax','The Tenement Saint','Doktor Puss','The Night Shift Killer',
|
||||
'Anya Vex','The Boiler Room Phantom','Father Crust','The Condemned Block Terror',
|
||||
'Samir Dread','The Rain Collector Killer','Mother Slake','The Long Corridor Predator',
|
||||
'Brix The Harvester','The Sub-Level Butcher','Sister Flay','The Cargo Hold Ripper',
|
||||
'Ulan Mort','The Work Camp Killer','Brother Gore','The Pressure Lock Garrotter',
|
||||
'Essa Writhe','The Tunnel System Surgeon','Doktor Scratch','The Generator Room Killer',
|
||||
'Lorn Vile','The Factory Floor Flayer','Mother Dreg','The Skyway Predator',
|
||||
'Kesh The Taker','The Water Tank Butcher','Father Ache','The Plaza Level Terror',
|
||||
'Nira Spite','The Underworld Saint','Sister Plague','The Transit Hub Ripper',
|
||||
'Drex The Silent','The Processing Plant Killer','Brother Peel','The Maintenance Shaft Ghoul',
|
||||
'Cela Void','The Archive Level Predator','Doktor Husk','The Night Canteen Killer',
|
||||
'Taun Fester','The Hab Block Surgeon','Mother Vex','The Overground Stalker',
|
||||
'Skar The Tender','The Pit Level Flayer','Father Necrox','The Downward Killer',
|
||||
'Zeal Mortis','The Sector 6 Ripper','Sister Drip','The Market Square Butcher',
|
||||
'Helm Lacerate','The Pipe Run Garrotter','Brother Wither','The Off-World Killer',
|
||||
'Elka Dissect','The Street Drain Predator','Doktor Flay','The Hab Level Surgeon',
|
||||
'Orn The Carver','The Data Corridor Killer','Mother Grippe','The Weight Room Ripper',
|
||||
'Caith Dread','The Sewer Saint','Father Craw','The Port Level Butcher',
|
||||
'Vrex The Bleeder','The Sub-Street Stalker','Sister Mire','The Pump Station Killer',
|
||||
'Akel Mortum','The Discharge Flayer','Brother Crust','The Skyview Garrotter',
|
||||
'Pelz The Hollow','The Hunger Level Killer','Doktor Dreg','The Night Ward Surgeon',
|
||||
'Thad Carnage','The Precinct Ripper','Mother Spite','The Slab Level Butcher',
|
||||
'Cren Vilemore','The Under-City Predator','Father Peel','The Clearance Level Killer',
|
||||
'Sora Dissect','The Waterway Stalker','Sister Bile','The Drop Zone Terror',
|
||||
'Lux The Opener','The Sector 8 Ripper','Brother Husk','The Med Bay Killer',
|
||||
'Ezra Mortax','The Night Crawler','Doktor Worm','The Pressure Dock Butcher',
|
||||
'Hesk Carnifax','The Enclosed Space Killer','Mother Lacerate','The Underplex Ripper',
|
||||
'Trix The Flayer','The Shift Change Predator','Father Spite','The Block Eight Killer',
|
||||
'Vera Dissect','The Grille Work Garrotter','Sister Fester','The Chasm Level Terror',
|
||||
'Orm The Carver','The Night Feed Killer','Brother Vile','The Inmate Level Flayer',
|
||||
'Keld Mortum','The Quiet Hours Stalker','Doktor Spine','The Slow Kill Surgeon',
|
||||
'Mace Carnage','The Sector Twelve Ripper','Mother Wither','The Grey Level Killer',
|
||||
'Dusk The Bleeder','The Contract Flayer','Father Gnash','The Contract Saint',
|
||||
'Rax Mortis','The Night Peel Killer','Sister Dreg','The Midnight Carver',
|
||||
'Udo The Hollow','The Long Night Stalker','Brother Vex','The Archive Ripper',
|
||||
'Noka Dissect','The Still Room Killer','Doktor Craven','The Sleep Level Butcher',
|
||||
'Phet The Opener','The Grease Level Predator','Mother Craw','The Skin Trade Killer',
|
||||
'Axel Mortax','The Null Level Ripper','Sister Spine','The Dawn Shift Butcher',
|
||||
'Kyre Carnifax','The Cold Room Surgeon','Father Fester','The Lockdown Garrotter',
|
||||
'Iden The Flayer','The Dead Zone Stalker','Brother Lacerate','The Sleep Cycle Killer',
|
||||
'Vrax Mortum','The Zero Level Ripper','Doktor Spite','The Hab Roof Predator',
|
||||
'Sela Dissect','The Dark Hours Flayer','Mother Husk','The Corridor Eight Killer',
|
||||
'Rend The Bleeder','The Processing Ripper','Father Worm','The Sub Nineteen Butcher',
|
||||
'Axia Carnage','The Weight Level Garrotter','Sister Craw','The Last Level Stalker',
|
||||
'Keth Mortis','The Overflow Carver',
|
||||
];
|
||||
|
||||
// 35 unique street gang handles (from 250 gang entries in the name reference)
|
||||
const GANG_HANDLES = [
|
||||
'Splice','Raze','Dagger','Bleed','Gouge','Hack','Spike','Crux',
|
||||
'Lash','Grind','Stitch','Bite','Smash','Crack','Tear','Fray',
|
||||
'Stomp','Slash','Wreck','Burn','Strip','Crush','Pound','Snap',
|
||||
'Twist','Kick','Rip','Gnaw','Maul','Cleave','Gut','Shred',
|
||||
'Slice','Stab','Punch',
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Archetype definitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Roll an integer between min and max (inclusive).
|
||||
*/
|
||||
function roll(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a random element from an array.
|
||||
*/
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
const ARCHETYPES = {
|
||||
civilian: {
|
||||
label: 'Civilian / Citizen',
|
||||
icon: 'fas fa-user',
|
||||
description: 'An ordinary denizen of the World of Progress — corp worker, street person, or shopkeeper.',
|
||||
defaultSpecies: ['human', 'ebon'],
|
||||
stats: {
|
||||
strength: [20, 35],
|
||||
speed: [20, 35],
|
||||
intellect: [25, 40],
|
||||
combat: [15, 30],
|
||||
sanity: [30, 45],
|
||||
fear: [30, 45],
|
||||
body: [20, 35],
|
||||
},
|
||||
armor: [0, 1],
|
||||
stress: [3, 6],
|
||||
resolve: [3, 6],
|
||||
skills: [
|
||||
{ name: 'Streetwise', rank: 'Trained', bonus: 10 },
|
||||
{ name: 'Evaluate', rank: 'Trained', bonus: 5 },
|
||||
],
|
||||
weapons: [
|
||||
{ name: 'Pocket Knife', damage: '1d5', ammo: 0, shots: 0, useAmmo: false, description: 'A small folding knife, more tool than weapon.' },
|
||||
],
|
||||
backgrounds: [
|
||||
'Debt-ridden corp drone grinding out a living in the lower sectors.',
|
||||
'A recently downsized block resident with nothing left to lose.',
|
||||
'Street vendor scraping by selling reconditioned tech.',
|
||||
'Off-shift factory worker from SubUrban housing block Sub-G4.',
|
||||
'Former Shivers recruit who washed out of basic training.',
|
||||
'Cafeteria worker at a mid-tier Cloak Division facility.',
|
||||
'Sector maintenance technician with a drinking problem.',
|
||||
'A disillusioned clerk who has seen too many BPN reports.',
|
||||
],
|
||||
operativeType: 'Civilian',
|
||||
portraitCategory: null, // use species
|
||||
},
|
||||
|
||||
shiver: {
|
||||
label: 'Shiver Operative',
|
||||
icon: 'fas fa-shield-alt',
|
||||
description: 'SLA Industries paramilitary police — armed response, patrol units, or Sector Rangers.',
|
||||
defaultSpecies: ['human', 'human', 'human', 'ebon', 'frother'],
|
||||
stats: {
|
||||
strength: [30, 45],
|
||||
speed: [30, 42],
|
||||
intellect: [25, 38],
|
||||
combat: [35, 50],
|
||||
sanity: [25, 40],
|
||||
fear: [25, 40],
|
||||
body: [30, 45],
|
||||
},
|
||||
armor: [4, 6],
|
||||
stress: [4, 8],
|
||||
resolve: [4, 7],
|
||||
skills: [
|
||||
{ name: 'Firearms', rank: 'Trained', bonus: 10 },
|
||||
{ name: 'Unarmed Combat', rank: 'Trained', bonus: 5 },
|
||||
{ name: 'Streetwise', rank: 'Trained', bonus: 5 },
|
||||
{ name: 'Interrogation', rank: 'Trained', bonus: 0 },
|
||||
],
|
||||
weapons: [
|
||||
{ name: 'Blitzer SMG', damage: '1d10+3', ammo: 30, shots: 1, useAmmo: true, ammoType: 'SMG', description: 'Standard-issue SLA Shiver automatic weapon. Fires STD rounds.' },
|
||||
{ name: 'GAU Automatic', damage: '1d10+2', ammo: 15, shots: 1, useAmmo: true, ammoType: 'Pistol', description: 'Compact sidearm issued to all Shiver operatives.' },
|
||||
],
|
||||
backgrounds: [
|
||||
'Third-year patrol officer assigned to a mid-tier sector. Follows orders without question.',
|
||||
'Armed response unit activated after a Carrien incursion, still twitchy from the last call.',
|
||||
'Veteran Shiver with 12 years service, completely loyal to the Division.',
|
||||
'Sector Ranger on anti-gang operations. Carries two extra magazines at all times.',
|
||||
'Recently promoted to Sergeant, keen to make an impression on the squad.',
|
||||
'Tasked with escorting a BPN team. Privately unhappy about the assignment.',
|
||||
'Under-resourced patrol officer stretched across three blocks with a part-time partner.',
|
||||
'Former Corporate Security secondee, integrating awkwardly with the Shiver unit.',
|
||||
],
|
||||
operativeType: 'Shiver',
|
||||
portraitCategory: null,
|
||||
},
|
||||
|
||||
carrien: {
|
||||
label: 'Carrien',
|
||||
icon: 'fas fa-skull-crossbones',
|
||||
description: 'A feral Carrien — fast, savage, barely human. A pack threat from the deep undercity.',
|
||||
defaultSpecies: ['human'],
|
||||
stats: {
|
||||
strength: [38, 55],
|
||||
speed: [40, 58],
|
||||
intellect: [5, 14],
|
||||
combat: [38, 55],
|
||||
sanity: [3, 10],
|
||||
fear: [3, 10],
|
||||
body: [32, 50],
|
||||
},
|
||||
armor: [0, 2],
|
||||
stress: [2, 3],
|
||||
resolve: [1, 2],
|
||||
skills: [
|
||||
{ name: 'Melee Combat', rank: 'Expert', bonus: 15 },
|
||||
],
|
||||
weapons: [
|
||||
{ name: 'Claws & Teeth', damage: '1d10+3', ammo: 0, shots: 0, useAmmo: false, description: 'Ragged nails, broken teeth, and the desperate strength of a cornered animal.' },
|
||||
],
|
||||
backgrounds: [
|
||||
'Feral pack-runner. Attacks on smell and movement.',
|
||||
'A former citizen, now unrecognisable after years below-sector.',
|
||||
'Scout for a larger Carrien pack moving through the sector walls.',
|
||||
'Driven above-sector by something even it fears deeper in the undercity.',
|
||||
'Territorial. Will not retreat. Will not negotiate.',
|
||||
'Freshly turned — still wearing the remnants of corp worker clothes.',
|
||||
],
|
||||
operativeType: 'Carrien',
|
||||
portraitCategory: 'animal_unknown', // Carrien → non-human animal pool
|
||||
isCreatureType: true,
|
||||
},
|
||||
|
||||
gang: {
|
||||
label: 'Street Gang',
|
||||
icon: 'fas fa-people-group',
|
||||
description: 'A block gang cell — generates multiple members at once with a shared street name and minimal stats.',
|
||||
isGangGroup: true, // triggers group-creation mode
|
||||
defaultSpecies: ['human'],
|
||||
// Per-member stats (combat-focused only — the rest defaults)
|
||||
stats: {
|
||||
strength: [25, 42],
|
||||
speed: [28, 42],
|
||||
intellect: [15, 25],
|
||||
combat: [28, 45],
|
||||
sanity: [12, 25],
|
||||
fear: [12, 25],
|
||||
body: [28, 45],
|
||||
},
|
||||
armor: [0, 3],
|
||||
stress: [2, 4],
|
||||
resolve: [2, 4],
|
||||
skills: [
|
||||
{ name: 'Melee Combat', rank: 'Trained', bonus: 10 },
|
||||
],
|
||||
// Gang weapons — each member gets ONE random pick from this list
|
||||
gangWeapons: [
|
||||
{ name: 'Power Blade', damage: '1d10+2', ammo: 0, shots: 0, useAmmo: false, description: 'A crude energy-edged blade.' },
|
||||
{ name: 'Vibro-blade', damage: '1d10+1', ammo: 0, shots: 0, useAmmo: false, description: 'A buzzing, fast-edged street weapon.' },
|
||||
{ name: 'Length of Pipe', damage: '1d6+3', ammo: 0, shots: 0, useAmmo: false, description: 'Blunt force trauma from a section of maintenance pipe.' },
|
||||
{ name: 'Meat Cleaver', damage: '1d6+4', ammo: 0, shots: 0, useAmmo: false, description: 'Repurposed kitchen hardware.' },
|
||||
{ name: 'Scrap Pistol', damage: '1d8', ammo: 6, shots: 1, useAmmo: true, ammoType: 'Pistol', description: 'Cobbled together from salvaged parts. May jam.' },
|
||||
{ name: 'Sawn-off Shotgun', damage: '2d6', ammo: 2, shots: 1, useAmmo: true, ammoType: 'Shotgun', description: 'Close-range devastation. Extremely loud.' },
|
||||
{ name: 'Knife', damage: '1d5+1', ammo: 0, shots: 0, useAmmo: false, description: 'A short, ugly blade worn on the hip.' },
|
||||
],
|
||||
operativeType: 'Gang Member',
|
||||
portraitCategory: null,
|
||||
},
|
||||
|
||||
corporate: {
|
||||
label: 'Corporate Agent',
|
||||
icon: 'fas fa-briefcase',
|
||||
description: 'Suits, executives, corporate security, and Power Corp operatives.',
|
||||
defaultSpecies: ['human', 'human', 'ebon', 'human', 'human'],
|
||||
stats: {
|
||||
strength: [22, 36],
|
||||
speed: [22, 36],
|
||||
intellect: [35, 52],
|
||||
combat: [28, 42],
|
||||
sanity: [32, 48],
|
||||
fear: [32, 48],
|
||||
body: [22, 36],
|
||||
},
|
||||
armor: [1, 4],
|
||||
stress: [3, 7],
|
||||
resolve: [4, 8],
|
||||
skills: [
|
||||
{ name: 'Persuasion', rank: 'Trained', bonus: 10 },
|
||||
{ name: 'Evaluate', rank: 'Expert', bonus: 10 },
|
||||
{ name: 'Intimidation', rank: 'Trained', bonus: 5 },
|
||||
{ name: 'Concealment', rank: 'Trained', bonus: 5 },
|
||||
],
|
||||
weapons: [
|
||||
{ name: 'Concealed Sidearm', damage: '1d8', ammo: 8, shots: 1, useAmmo: true, ammoType: 'Pistol', description: 'A compact, high-quality pistol. Discreetly holstered.' },
|
||||
],
|
||||
backgrounds: [
|
||||
'Mid-level executive overseeing a sensitive data review. Carries a deadlock chip.',
|
||||
'Corporate Security agent embedded in a BPN team to monitor SLA Operatives.',
|
||||
'White Corporation representative with interests in the local sector economy.',
|
||||
'Blue Corporation efficiency consultant. Currently under non-disclosure contract.',
|
||||
'Power Corp negotiator — calm, well-dressed, and absolutely ruthless.',
|
||||
'Corporate fixer tasked with cleaning up a situation that never officially happened.',
|
||||
'Loyal company man with three promotions and no conscience.',
|
||||
'Financial auditor dispatched to oversee a "restructuring" of sector resources.',
|
||||
],
|
||||
operativeType: 'Corporate',
|
||||
portraitCategory: null,
|
||||
},
|
||||
|
||||
mutant: {
|
||||
label: 'Mutant / Serial Killer',
|
||||
icon: 'fas fa-biohazard',
|
||||
description: 'Mutated threats, deranged killers, and Manchine-adjacent horrors from the deep sectors.',
|
||||
defaultSpecies: ['human', 'human', 'human'],
|
||||
stats: {
|
||||
strength: [40, 58],
|
||||
speed: [22, 38],
|
||||
intellect: [8, 22],
|
||||
combat: [40, 58],
|
||||
sanity: [5, 18],
|
||||
fear: [5, 18],
|
||||
body: [42, 60],
|
||||
},
|
||||
armor: [0, 4],
|
||||
stress: [2, 4],
|
||||
resolve: [1, 3],
|
||||
skills: [
|
||||
{ name: 'Melee Combat', rank: 'Expert', bonus: 10 },
|
||||
{ name: 'Intimidation', rank: 'Expert', bonus: 5 },
|
||||
],
|
||||
weapons: [
|
||||
{ name: 'Improvised Weapon', damage: '1d10+4', ammo: 0, shots: 0, useAmmo: false, description: 'A brutal tool of violence. Could be a length of pipe, a modified saw, or something worse.' },
|
||||
],
|
||||
backgrounds: [
|
||||
'A deep-sector mutant driven to violence by prolonged exposure to unstable compounds.',
|
||||
'Serial killer active in the lower blocks for 18 months. Three kills confirmed, others suspected.',
|
||||
'Manchine-touched survivor of a containment breach. No longer fully human.',
|
||||
'A former SLA worker driven mad by a classified experiment gone catastrophically wrong.',
|
||||
'Something that used to be a person, before the sector chewed it up and spat it back out.',
|
||||
'Subject of an illegal gene-therapy programme. Currently at large and extremely dangerous.',
|
||||
'A traumatised war vet whose grip on reality dissolved years ago.',
|
||||
'DarkNight insurgent pushed past the point of sanity by their conditioning.',
|
||||
],
|
||||
operativeType: 'Threat',
|
||||
portraitCategory: 'mutant_unknown', // Serial killer / mutant pool
|
||||
},
|
||||
};
|
||||
|
||||
const SPECIES_OPTIONS = [
|
||||
{ value: 'random', label: 'Random (archetype-appropriate)' },
|
||||
{ value: 'human', label: 'Human' },
|
||||
{ value: 'ebon', label: 'Ebon' },
|
||||
{ value: 'frother', label: 'Frother' },
|
||||
{ value: 'shaktar', label: 'Shaktar' },
|
||||
{ value: 'stormer', label: 'Stormer' },
|
||||
{ value: 'waister', label: 'Waister' },
|
||||
{ value: 'wraith', label: 'Wraith' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Name generation
|
||||
// ============================================================================
|
||||
|
||||
function generateName(species, archetypeKey) {
|
||||
// Serial killers / mutants get a pre-built infamous identity
|
||||
if (archetypeKey === 'mutant') {
|
||||
return pick(SERIAL_KILLER_NAMES);
|
||||
}
|
||||
|
||||
// Stormer names are alphanumeric designations
|
||||
if (species === 'stormer') {
|
||||
const designations = ['Storm','Bravo','Vector','Apex','Delta','Echo','Grid','Havoc','Iron','Jackal','Kilo','Lima'];
|
||||
return `${pick(designations)}-${roll(1,9)}${roll(0,9)}`;
|
||||
}
|
||||
|
||||
// All other species: pick from the 485-name first-name pool + 500 surnames
|
||||
const firstName = pick(FIRST_NAMES);
|
||||
const surname = pick(SURNAMES);
|
||||
return `${firstName} ${surname}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stat generation
|
||||
// ============================================================================
|
||||
|
||||
function generateStats(archetype) {
|
||||
const def = ARCHETYPES[archetype];
|
||||
const stats = {};
|
||||
for (const [key, [min, max]] of Object.entries(def.stats)) {
|
||||
stats[key] = { value: roll(min, max), mod: 0 };
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Item creation helpers
|
||||
// ============================================================================
|
||||
|
||||
function makeSkillItemData(skill) {
|
||||
return {
|
||||
name: skill.name,
|
||||
type: 'skill',
|
||||
system: {
|
||||
rank: skill.rank,
|
||||
bonus: skill.bonus ?? 0,
|
||||
sla: { category: '', skillFamily: '', source: 'Generated' },
|
||||
description: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeWeaponItemData(weapon) {
|
||||
return {
|
||||
name: weapon.name,
|
||||
type: 'weapon',
|
||||
system: {
|
||||
damage: weapon.damage ?? '1d10',
|
||||
ammo: weapon.ammo ?? 0,
|
||||
shots: weapon.shots ?? 1,
|
||||
curShots: weapon.ammo ?? 0,
|
||||
shotsPerFire: 1,
|
||||
useAmmo: weapon.useAmmo ?? false,
|
||||
ammoType: weapon.ammoType ?? '',
|
||||
ammoCalibre: '',
|
||||
ammoTag: 'STD',
|
||||
ammoLoadedType: 'STD',
|
||||
ammoReserve: weapon.ammo ?? 0,
|
||||
ammoReserveStd: weapon.ammo ?? 0,
|
||||
ammoReserveAp: 0,
|
||||
ammoReserveHe: 0,
|
||||
ammoReserveHeap: 0,
|
||||
wound: '',
|
||||
critDmg: '',
|
||||
woundEffect: '',
|
||||
description: weapon.description ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main generation function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate one NPC actor document.
|
||||
* @param {string} archetypeKey - Key from ARCHETYPES
|
||||
* @param {string} speciesChoice - 'random' or a specific species
|
||||
* @param {PortraitManager} manager
|
||||
* @param {string|null} folderId
|
||||
* @returns {Promise<Actor>}
|
||||
*/
|
||||
async function generateNpc(archetypeKey, speciesChoice, manager, folderId) {
|
||||
const def = ARCHETYPES[archetypeKey];
|
||||
const isCreature = def?.isCreatureType ?? (archetypeKey === 'mutant');
|
||||
|
||||
// Resolve species
|
||||
const species = speciesChoice === 'random'
|
||||
? pick(def.defaultSpecies)
|
||||
: speciesChoice;
|
||||
|
||||
// Generate name
|
||||
const name = generateName(species, archetypeKey);
|
||||
|
||||
// Generate stats
|
||||
const stats = generateStats(archetypeKey);
|
||||
|
||||
// Health = body stat (simplified; Foundry system will recalculate in some builds)
|
||||
const bodyVal = stats.body?.value ?? 30;
|
||||
const healthVal = Math.max(5, Math.floor(bodyVal / 2));
|
||||
|
||||
// Armor
|
||||
const armorVal = roll(def.armor[0], def.armor[1]);
|
||||
|
||||
// Stress / Resolve
|
||||
const stressMax = roll(def.stress[0], def.stress[1]);
|
||||
const resolveMax = roll(def.resolve[0], def.resolve[1]);
|
||||
|
||||
// Pick portrait
|
||||
let portraitPath = null;
|
||||
const portraitCat = def.portraitCategory;
|
||||
|
||||
if (manager.portraits.length > 0) {
|
||||
// Broad type map: species → human | mutant | animal (matches portrait-manager.mjs)
|
||||
const SPECIES_TO_TYPE = {
|
||||
human: 'human', ebon: 'human', stormer: 'human',
|
||||
frother: 'mutant',
|
||||
shaktar: 'animal', waister: 'animal', wraith: 'animal',
|
||||
};
|
||||
|
||||
let portrait;
|
||||
if (portraitCat) {
|
||||
// Archetype has an explicit override (carrien → animal_unknown, mutant → mutant_unknown)
|
||||
portrait = manager.getRandomPortrait({ category: portraitCat });
|
||||
} else if (isCreature) {
|
||||
portrait = manager.getRandomPortrait({ category: 'animal_unknown' });
|
||||
} else {
|
||||
// Map species to broad type, pick gendered or unknown pool
|
||||
const broadType = SPECIES_TO_TYPE[species] ?? 'human';
|
||||
// Try gendered first (both male + female), then fall back to unknown
|
||||
portrait = manager.getRandomPortrait({
|
||||
category: [`${broadType}_male`, `${broadType}_female`, `${broadType}_unknown`],
|
||||
});
|
||||
}
|
||||
portraitPath = portrait?.path ?? null;
|
||||
}
|
||||
|
||||
// Background
|
||||
const background = pick(def.backgrounds);
|
||||
|
||||
// Actor type: mutant/creature uses 'creature', everything else uses 'character'
|
||||
const actorType = isCreature ? 'creature' : 'character';
|
||||
|
||||
// Build actor data
|
||||
const actorData = {
|
||||
name,
|
||||
type: actorType,
|
||||
img: portraitPath ?? 'icons/svg/mystery-man.svg',
|
||||
folder: folderId ?? null,
|
||||
system: {
|
||||
health: { value: healthVal, min: 0, max: healthVal },
|
||||
biography: background,
|
||||
notes: `Archetype: ${def.label}\nSpecies: ${species}\nOperative Type: ${def.operativeType}`,
|
||||
stats,
|
||||
other: {
|
||||
stress: { value: 2, min: 2, max: stressMax },
|
||||
resolve: { value: resolveMax, min: 0, max: resolveMax },
|
||||
},
|
||||
sla: {
|
||||
operativeType: { value: def.operativeType },
|
||||
species: { value: species.charAt(0).toUpperCase() + species.slice(1) },
|
||||
},
|
||||
},
|
||||
prototypeToken: {
|
||||
name,
|
||||
disposition: CONST.TOKEN_DISPOSITIONS.HOSTILE,
|
||||
texture: { src: portraitPath ?? 'icons/svg/mystery-man.svg' },
|
||||
sight: { enabled: false },
|
||||
actorLink: false,
|
||||
randomImg: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Create actor
|
||||
const actor = await Actor.create(actorData);
|
||||
if (!actor) return null;
|
||||
|
||||
// Add armor stat update (armor is a stat on the character)
|
||||
await actor.update({ 'system.stats.armor.value': armorVal });
|
||||
|
||||
// Create embedded item documents (skills + weapons)
|
||||
const itemsToCreate = [
|
||||
...def.skills.map(s => makeSkillItemData(s)),
|
||||
...def.weapons.map(w => makeWeaponItemData(w)),
|
||||
];
|
||||
|
||||
if (itemsToCreate.length > 0) {
|
||||
await actor.createEmbeddedDocuments('Item', itemsToCreate);
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gang group generation — creates a batch of lightweight grunt actors
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a street gang cell of `count` members.
|
||||
* Each member gets one name, minimal combat stats, and one random weapon.
|
||||
* @param {number} count
|
||||
* @param {PortraitManager} manager
|
||||
* @param {string|null} folderId
|
||||
* @returns {Promise<Actor[]>}
|
||||
*/
|
||||
async function generateGangMembers(count, manager, folderId) {
|
||||
const def = ARCHETYPES.gang;
|
||||
const created = [];
|
||||
|
||||
// Track used handles in this batch to avoid duplicates
|
||||
const usedHandles = new Set();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Pick a unique handle for this member
|
||||
let handle;
|
||||
const availableHandles = GANG_HANDLES.filter(h => !usedHandles.has(h));
|
||||
if (availableHandles.length > 0) {
|
||||
handle = pick(availableHandles);
|
||||
usedHandles.add(handle);
|
||||
} else {
|
||||
// Pool exhausted — append a number
|
||||
handle = `${pick(GANG_HANDLES)}-${i + 1}`;
|
||||
}
|
||||
|
||||
// Roll minimal combat-relevant stats only
|
||||
const combatVal = roll(def.stats.combat[0], def.stats.combat[1]);
|
||||
const bodyVal = roll(def.stats.body[0], def.stats.body[1]);
|
||||
const speedVal = roll(def.stats.speed[0], def.stats.speed[1]);
|
||||
const strengthVal = roll(def.stats.strength[0],def.stats.strength[1]);
|
||||
const healthVal = Math.max(4, Math.floor(bodyVal / 3));
|
||||
const armorVal = roll(def.armor[0], def.armor[1]);
|
||||
|
||||
// Gang members are street humans — draw from human pool
|
||||
let portraitPath = null;
|
||||
if (manager.portraits.length > 0) {
|
||||
const p = manager.getRandomPortrait({ category: ['human_male', 'human_female', 'human_unknown'] });
|
||||
portraitPath = p?.path ?? null;
|
||||
}
|
||||
|
||||
// Pick a random weapon from the gang weapons list
|
||||
const weapon = pick(def.gangWeapons);
|
||||
|
||||
const actorData = {
|
||||
name: handle,
|
||||
type: 'character',
|
||||
img: portraitPath ?? 'icons/svg/mystery-man.svg',
|
||||
folder: folderId ?? null,
|
||||
system: {
|
||||
health: { value: healthVal, min: 0, max: healthVal },
|
||||
biography: `Street gang member. No past worth mentioning.`,
|
||||
notes: `Archetype: Street Gang\nArmor: ${armorVal}\nCombat: ${combatVal}`,
|
||||
stats: {
|
||||
strength: { value: strengthVal, mod: 0 },
|
||||
speed: { value: speedVal, mod: 0 },
|
||||
intellect: { value: roll(15, 25), mod: 0 },
|
||||
combat: { value: combatVal, mod: 0 },
|
||||
sanity: { value: roll(12, 22), mod: 0 },
|
||||
fear: { value: roll(12, 22), mod: 0 },
|
||||
body: { value: bodyVal, mod: 0 },
|
||||
armor: { value: armorVal, mod: 0, damageReduction: 0 },
|
||||
},
|
||||
other: {
|
||||
stress: { value: 2, min: 2, max: roll(2, 4) },
|
||||
resolve: { value: 2, min: 0, max: roll(2, 4) },
|
||||
},
|
||||
sla: {
|
||||
operativeType: { value: 'Gang Member' },
|
||||
species: { value: 'Human' },
|
||||
},
|
||||
},
|
||||
prototypeToken: {
|
||||
name,
|
||||
disposition: CONST.TOKEN_DISPOSITIONS.HOSTILE,
|
||||
texture: { src: portraitPath ?? 'icons/svg/mystery-man.svg' },
|
||||
sight: { enabled: false },
|
||||
actorLink: false,
|
||||
randomImg: false,
|
||||
},
|
||||
};
|
||||
|
||||
const actor = await Actor.create(actorData);
|
||||
if (!actor) continue;
|
||||
|
||||
// Embed the weapon item
|
||||
await actor.createEmbeddedDocuments('Item', [ makeWeaponItemData(weapon) ]);
|
||||
|
||||
created.push(actor);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UI helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Show/hide the gang-size row and the standard count/species rows
|
||||
* depending on whether the 'gang' archetype is selected.
|
||||
* @param {HTMLElement} root
|
||||
* @param {string} archetypeKey
|
||||
*/
|
||||
function _toggleGangRow(root, archetypeKey) {
|
||||
const isGang = ARCHETYPES[archetypeKey]?.isGangGroup ?? false;
|
||||
|
||||
const gangRow = root.querySelector('.gen-gang-size-row');
|
||||
const countRow = root.querySelector('.gen-count-species-row');
|
||||
const speciesRow = root.querySelector('.gen-species-field');
|
||||
|
||||
if (gangRow) gangRow.style.display = isGang ? '' : 'none';
|
||||
if (countRow) countRow.style.display = isGang ? 'none' : '';
|
||||
if (speciesRow) speciesRow.style.display = isGang ? 'none' : '';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NpcGeneratorDialog — FormApplication / ApplicationV2 adaptive dialog
|
||||
// ============================================================================
|
||||
|
||||
function buildGeneratorClass() {
|
||||
if (foundry.applications?.api?.ApplicationV2) {
|
||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
|
||||
return class NpcGeneratorDialog extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
|
||||
constructor(manager, opts = {}) {
|
||||
super(opts);
|
||||
this.manager = manager;
|
||||
this._busy = false;
|
||||
this._lastLog = [];
|
||||
}
|
||||
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: 'npc-generator-dialog',
|
||||
tag: 'form',
|
||||
classes: ['npc-portrait-pack', 'npc-generator'],
|
||||
window: {
|
||||
title: 'SLA Industries NPC Generator',
|
||||
resizable: true,
|
||||
},
|
||||
position: { width: 480, height: 'auto' },
|
||||
form: {
|
||||
handler: NpcGeneratorDialog._onGenerate,
|
||||
submitOnChange: false,
|
||||
closeOnSubmit: false,
|
||||
},
|
||||
};
|
||||
|
||||
static PARTS = {
|
||||
form: {
|
||||
template: `modules/${MODULE_ID}/templates/npc-generator.hbs`,
|
||||
}
|
||||
};
|
||||
|
||||
async _prepareContext(_opts) {
|
||||
return {
|
||||
archetypes: Object.entries(ARCHETYPES).map(([k, v]) => ({ value: k, label: v.label, icon: v.icon, description: v.description })),
|
||||
species: SPECIES_OPTIONS,
|
||||
log: this._lastLog,
|
||||
};
|
||||
}
|
||||
|
||||
_onRender(context, opts) {
|
||||
super._onRender?.(context, opts);
|
||||
const root = this.element;
|
||||
|
||||
// Archetype card selection → update description + toggle gang-size row
|
||||
root.querySelectorAll('[name="archetype"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const key = radio.value;
|
||||
const desc = ARCHETYPES[key]?.description ?? '';
|
||||
const el = root.querySelector('.archetype-description');
|
||||
if (el) el.textContent = desc;
|
||||
// Toggle selected class
|
||||
root.querySelectorAll('.archetype-card').forEach(c => c.classList.remove('selected'));
|
||||
radio.closest('.archetype-card')?.classList.add('selected');
|
||||
// Show gang-size row only when gang is selected
|
||||
_toggleGangRow(root, key);
|
||||
});
|
||||
});
|
||||
|
||||
// Set initial state
|
||||
const checked = root.querySelector('[name="archetype"]:checked');
|
||||
if (checked) _toggleGangRow(root, checked.value);
|
||||
}
|
||||
|
||||
static async _onGenerate(event, form, formData) {
|
||||
if (this._busy) return;
|
||||
this._busy = true;
|
||||
this._lastLog = [];
|
||||
|
||||
const data = foundry.utils.expandObject(formData.object);
|
||||
const archetype = data.archetype ?? 'civilian';
|
||||
const species = data.species ?? 'random';
|
||||
const folderId = data.folderId || null;
|
||||
const def = ARCHETYPES[archetype];
|
||||
|
||||
let created = [];
|
||||
|
||||
if (def?.isGangGroup) {
|
||||
// Street gang — batch creation of grunts
|
||||
const gangSize = Math.max(2, Math.min(20, parseInt(data.gangSize ?? 5)));
|
||||
created = await generateGangMembers(gangSize, this.manager, folderId);
|
||||
for (const a of created) {
|
||||
this._lastLog.push(`✓ ${a.name} (Gang Member)`);
|
||||
}
|
||||
} else {
|
||||
// Individual NPC(s)
|
||||
const count = Math.max(1, Math.min(10, parseInt(data.count ?? 1)));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const actor = await generateNpc(archetype, species, this.manager, folderId);
|
||||
if (actor) {
|
||||
created.push(actor);
|
||||
this._lastLog.push(`✓ Created: ${actor.name} (${def?.label ?? archetype})`);
|
||||
} else {
|
||||
this._lastLog.push(`✗ Failed to create NPC ${i + 1}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._busy = false;
|
||||
ui.notifications.info(`NPC Generator: ${created.length} actor${created.length !== 1 ? 's' : ''} created.`);
|
||||
await this.render();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Foundry v11/v12 — FormApplication fallback
|
||||
return class NpcGeneratorDialog extends FormApplication {
|
||||
|
||||
constructor(manager) {
|
||||
super();
|
||||
this.manager = manager;
|
||||
this._busy = false;
|
||||
this._lastLog = [];
|
||||
}
|
||||
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: 'npc-generator-dialog',
|
||||
title: 'SLA Industries NPC Generator',
|
||||
template: `modules/${MODULE_ID}/templates/npc-generator.hbs`,
|
||||
width: 480,
|
||||
height: 'auto',
|
||||
resizable: true,
|
||||
classes: ['npc-portrait-pack', 'npc-generator'],
|
||||
});
|
||||
}
|
||||
|
||||
getData() {
|
||||
return {
|
||||
archetypes: Object.entries(ARCHETYPES).map(([k, v]) => ({ value: k, label: v.label, icon: v.icon, description: v.description })),
|
||||
species: SPECIES_OPTIONS,
|
||||
log: this._lastLog,
|
||||
};
|
||||
}
|
||||
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
const root = html[0];
|
||||
|
||||
// Update description text + toggle gang row when archetype changes
|
||||
html.find('[name="archetype"]').on('change', (ev) => {
|
||||
const key = ev.currentTarget.value;
|
||||
const desc = ARCHETYPES[key]?.description ?? '';
|
||||
html.find('.archetype-description').text(desc);
|
||||
_toggleGangRow(root, key);
|
||||
// Toggle selected class
|
||||
html.find('.archetype-card').removeClass('selected');
|
||||
$(ev.currentTarget).closest('.archetype-card').addClass('selected');
|
||||
});
|
||||
|
||||
// Set initial state
|
||||
const checked = root.querySelector('[name="archetype"]:checked');
|
||||
if (checked) _toggleGangRow(root, checked.value);
|
||||
}
|
||||
|
||||
async _updateObject(_event, formData) {
|
||||
if (this._busy) return;
|
||||
this._busy = true;
|
||||
this._lastLog = [];
|
||||
|
||||
const archetype = formData.archetype ?? 'civilian';
|
||||
const species = formData.species ?? 'random';
|
||||
const folderId = formData.folderId || null;
|
||||
const def = ARCHETYPES[archetype];
|
||||
|
||||
let created = [];
|
||||
|
||||
if (def?.isGangGroup) {
|
||||
const gangSize = Math.max(2, Math.min(20, parseInt(formData.gangSize ?? 5)));
|
||||
created = await generateGangMembers(gangSize, this.manager, folderId);
|
||||
for (const a of created) {
|
||||
this._lastLog.push(`✓ ${a.name} (Gang Member)`);
|
||||
}
|
||||
} else {
|
||||
const count = Math.max(1, Math.min(10, parseInt(formData.count ?? 1)));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const actor = await generateNpc(archetype, species, this.manager, folderId);
|
||||
if (actor) {
|
||||
created.push(actor);
|
||||
this._lastLog.push(`✓ Created: ${actor.name} (${def?.label ?? archetype})`);
|
||||
} else {
|
||||
this._lastLog.push(`✗ Failed to create NPC ${i + 1}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._busy = false;
|
||||
ui.notifications.info(`NPC Generator: ${created.length} actor${created.length !== 1 ? 's' : ''} created.`);
|
||||
this.render(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const NpcGeneratorDialog = buildGeneratorClass();
|
||||
|
||||
// Export archetype data for external use
|
||||
export { ARCHETYPES, SPECIES_OPTIONS, generateNpc };
|
||||
@@ -0,0 +1,138 @@
|
||||
// npc-portrait-pack.mjs — Main entry point
|
||||
// Foundry VTT module: NPC Portrait Pack + Generator (SLA Industries edition)
|
||||
|
||||
import { PortraitManager } from './portrait-manager.mjs';
|
||||
import { registerSettings, MODULE_ID } from './settings.mjs';
|
||||
import { PortraitPickerDialog } from './portrait-dialog.mjs';
|
||||
import { NpcGeneratorDialog } from './npc-generator.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// init — register settings before anything else
|
||||
// ---------------------------------------------------------------------------
|
||||
Hooks.once('init', () => {
|
||||
registerSettings();
|
||||
console.log('NPC Portrait Pack | Initialised');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ready — load portrait index, expose public API
|
||||
// ---------------------------------------------------------------------------
|
||||
Hooks.once('ready', async () => {
|
||||
const manager = new PortraitManager();
|
||||
await manager.loadIndex();
|
||||
|
||||
game.npcPortraitPack = {
|
||||
getRandomPortrait: (opts = {}) => manager.getRandomPortrait(opts),
|
||||
assignPortraitToActor: (actor, opts = {}) => manager.assignPortraitToActor(actor, opts),
|
||||
filterPortraits: (opts = {}) => manager.filterPortraits(opts),
|
||||
portraitOptsForActor: (actor) => manager.portraitOptsForActor(actor),
|
||||
|
||||
openPickerDialog: (actor) => {
|
||||
new PortraitPickerDialog(actor, manager).render(true);
|
||||
},
|
||||
|
||||
openGeneratorDialog: () => {
|
||||
new NpcGeneratorDialog(manager).render(true);
|
||||
},
|
||||
|
||||
manager,
|
||||
};
|
||||
|
||||
Hooks.callAll('npcPortraitPack.ready', game.npcPortraitPack);
|
||||
console.log('NPC Portrait Pack | Ready → game.npcPortraitPack');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createActor hook — auto-assign portrait to new NPC actors
|
||||
// ---------------------------------------------------------------------------
|
||||
Hooks.on('createActor', async (actor, _options, userId) => {
|
||||
if (game.userId !== userId) return;
|
||||
if (!game.settings.get(MODULE_ID, 'autoAssign')) return;
|
||||
if (actor.hasPlayerOwner || actor.type === 'ship') return;
|
||||
|
||||
if (!game.npcPortraitPack) {
|
||||
await new Promise(resolve => Hooks.once('npcPortraitPack.ready', resolve));
|
||||
}
|
||||
|
||||
await game.npcPortraitPack.assignPortraitToActor(actor);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sheet header buttons — inject "Portrait" and "Generate NPC" buttons
|
||||
//
|
||||
// In Foundry v13, _getHeaderButtons() calls:
|
||||
// Hooks.callAll(`get${this.constructor.name}HeaderButtons`, sheet, buttons)
|
||||
// so `getActorSheetHeaderButtons` does NOT fire for subclasses.
|
||||
//
|
||||
// Instead we use renderActorSheet which fires for ALL ActorSheet subclasses,
|
||||
// and inject our buttons directly into the DOM.
|
||||
// ---------------------------------------------------------------------------
|
||||
Hooks.on('renderActorSheet', (sheet, html, _data) => {
|
||||
if (!game.user?.isGM) return;
|
||||
const actor = sheet.actor ?? sheet.object;
|
||||
if (!actor || actor.type === 'ship') return;
|
||||
|
||||
// Resolve jQuery or plain DOM root
|
||||
const root = html instanceof jQuery ? html[0] : html;
|
||||
|
||||
// Find the header controls area — works for both appv1 and appv2 layouts
|
||||
const headerBtns = root.querySelector('.window-header .header-button')?.parentElement
|
||||
?? root.querySelector('.header-controls')
|
||||
?? root.querySelector('.window-header');
|
||||
if (!headerBtns) return;
|
||||
|
||||
// Avoid duplicate injection on re-renders
|
||||
if (root.querySelector('.npp-portrait-btn')) return;
|
||||
|
||||
// ── Portrait picker button ──────────────────────────────────────
|
||||
const portraitBtn = document.createElement('a');
|
||||
portraitBtn.className = 'header-button control npp-portrait-btn';
|
||||
portraitBtn.title = 'Assign Portrait';
|
||||
portraitBtn.innerHTML = '<i class="fas fa-portrait"></i><label>Portrait</label>';
|
||||
portraitBtn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!game.npcPortraitPack) return ui.notifications.warn('NPC Portrait Pack not ready.');
|
||||
game.npcPortraitPack.openPickerDialog(actor);
|
||||
});
|
||||
|
||||
// ── Generator button (GM toolbar, not actor-specific) ──────────
|
||||
// Only show on non-player-owned actors, or always on the toolbar
|
||||
const genBtn = document.createElement('a');
|
||||
genBtn.className = 'header-button control npp-generate-btn';
|
||||
genBtn.title = 'Open NPC Generator';
|
||||
genBtn.innerHTML = '<i class="fas fa-user-plus"></i><label>Generate NPC</label>';
|
||||
genBtn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!game.npcPortraitPack) return ui.notifications.warn('NPC Portrait Pack not ready.');
|
||||
game.npcPortraitPack.openGeneratorDialog();
|
||||
});
|
||||
|
||||
// Prepend — so they appear before the default close/configure buttons
|
||||
headerBtns.prepend(genBtn);
|
||||
headerBtns.prepend(portraitBtn);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register a "Generate NPC" button in the Actors sidebar tab header
|
||||
// ---------------------------------------------------------------------------
|
||||
Hooks.on('renderActorDirectory', (_app, html) => {
|
||||
if (!game.user?.isGM) return;
|
||||
const root = html instanceof jQuery ? html[0] : html;
|
||||
|
||||
if (root.querySelector('.npp-generate-sidebar-btn')) return;
|
||||
|
||||
const header = root.querySelector('.directory-header .action-buttons')
|
||||
?? root.querySelector('.header-actions');
|
||||
if (!header) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'npp-generate-sidebar-btn';
|
||||
btn.title = 'Generate SLA NPC';
|
||||
btn.innerHTML = '<i class="fas fa-user-plus"></i> Generate NPC';
|
||||
btn.addEventListener('click', () => {
|
||||
if (!game.npcPortraitPack) return ui.notifications.warn('NPC Portrait Pack not ready.');
|
||||
game.npcPortraitPack.openGeneratorDialog();
|
||||
});
|
||||
|
||||
header.appendChild(btn);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// portrait-dialog.mjs — Portrait Picker dialog (Foundry v11/v12/v13 compatible)
|
||||
|
||||
import { MODULE_ID } from './settings.mjs';
|
||||
|
||||
// The categories available in the picker UI, with display labels.
|
||||
// Three broad aesthetic types: human | mutant | animal
|
||||
const CATEGORIES = [
|
||||
{ value: 'any', label: 'All Portraits (799)' },
|
||||
// ── Human-presenting ──────────────────────────────
|
||||
{ value: 'human_female', label: 'Human — Female (97)' },
|
||||
{ value: 'human_male', label: 'Human — Male (98)' },
|
||||
{ value: 'human_unknown', label: 'Human — Unknown gender (367)' },
|
||||
// ── Mutant / altered humans ────────────────────────
|
||||
{ value: 'mutant_female', label: 'Mutant — Female (41)' },
|
||||
{ value: 'mutant_male', label: 'Mutant — Male (41)' },
|
||||
{ value: 'mutant_unknown', label: 'Mutant — Unknown gender (57)' },
|
||||
// ── Non-human / alien ─────────────────────────────
|
||||
{ value: 'animal_female', label: 'Animal / Alien — Female (38)' },
|
||||
{ value: 'animal_male', label: 'Animal / Alien — Male (36)' },
|
||||
{ value: 'animal_unknown', label: 'Animal / Alien — Unknown (24)' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Foundry v13 uses ApplicationV2; v11/v12 uses FormApplication.
|
||||
// We detect which is available and subclass accordingly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildPickerClass() {
|
||||
// Foundry v13+ — ApplicationV2 available
|
||||
if (foundry.applications?.api?.ApplicationV2) {
|
||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
|
||||
return class PortraitPickerDialog extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
|
||||
constructor(actor, manager, opts = {}) {
|
||||
super(opts);
|
||||
this.actor = actor;
|
||||
this.manager = manager;
|
||||
this._selectedCategory = game.settings.get(MODULE_ID, 'defaultCategory') ?? 'any';
|
||||
}
|
||||
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: 'npc-portrait-picker',
|
||||
tag: 'form',
|
||||
classes: ['npc-portrait-pack', 'portrait-picker'],
|
||||
window: {
|
||||
title: 'NPC Portrait Picker',
|
||||
resizable: true,
|
||||
},
|
||||
position: { width: 720, height: 580 },
|
||||
actions: {
|
||||
selectPortrait: PortraitPickerDialog._onSelectPortrait,
|
||||
randomPortrait: PortraitPickerDialog._onRandomPortrait,
|
||||
},
|
||||
};
|
||||
|
||||
static PARTS = {
|
||||
form: {
|
||||
template: `modules/${MODULE_ID}/templates/portrait-picker.hbs`,
|
||||
}
|
||||
};
|
||||
|
||||
async _prepareContext(_opts) {
|
||||
const portraits = this.manager.filterPortraits({ category: this._selectedCategory });
|
||||
return {
|
||||
actor: this.actor,
|
||||
portraits,
|
||||
categories: CATEGORIES,
|
||||
selectedCategory: this._selectedCategory,
|
||||
};
|
||||
}
|
||||
|
||||
_onRender(context, opts) {
|
||||
super._onRender?.(context, opts);
|
||||
// Category dropdown change
|
||||
this.element.querySelector('.category-filter')?.addEventListener('change', async (ev) => {
|
||||
this._selectedCategory = ev.currentTarget.value;
|
||||
await this.render();
|
||||
});
|
||||
}
|
||||
|
||||
static async _onSelectPortrait(event, target) {
|
||||
const path = target.dataset.path;
|
||||
await this._applyPortrait(path);
|
||||
}
|
||||
|
||||
static async _onRandomPortrait(_event, _target) {
|
||||
const portraits = this.manager.filterPortraits({ category: this._selectedCategory });
|
||||
if (!portraits.length) return;
|
||||
const chosen = portraits[Math.floor(Math.random() * portraits.length)];
|
||||
await this._applyPortrait(chosen.path);
|
||||
}
|
||||
|
||||
async _applyPortrait(path) {
|
||||
const setAvatar = game.settings.get(MODULE_ID, 'setAvatar');
|
||||
const updateData = { 'prototypeToken.texture.src': path };
|
||||
if (setAvatar) updateData.img = path;
|
||||
await this.actor.update(updateData);
|
||||
ui.notifications.info(
|
||||
game.i18n.format('NPC_PORTRAIT_PACK.Notifications.Assigned', { actorName: this.actor.name })
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Foundry v11/v12 — FormApplication fallback
|
||||
return class PortraitPickerDialog extends FormApplication {
|
||||
|
||||
constructor(actor, manager) {
|
||||
super();
|
||||
this.actor = actor;
|
||||
this.manager = manager;
|
||||
this._category = game.settings.get(MODULE_ID, 'defaultCategory') ?? 'any';
|
||||
}
|
||||
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
id: 'npc-portrait-picker',
|
||||
title: 'NPC Portrait Picker',
|
||||
template: `modules/${MODULE_ID}/templates/portrait-picker.hbs`,
|
||||
width: 720,
|
||||
height: 580,
|
||||
resizable: true,
|
||||
classes: ['npc-portrait-pack', 'portrait-picker'],
|
||||
});
|
||||
}
|
||||
|
||||
getData() {
|
||||
const portraits = this.manager.filterPortraits({ category: this._category });
|
||||
return {
|
||||
actor: this.actor,
|
||||
portraits,
|
||||
categories: CATEGORIES,
|
||||
selectedCategory: this._category,
|
||||
};
|
||||
}
|
||||
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
html.find('.portrait-thumb').on('click', async (ev) => {
|
||||
const path = ev.currentTarget.dataset.path;
|
||||
await this._applyPortrait(path);
|
||||
});
|
||||
|
||||
html.find('.btn-random').on('click', async () => {
|
||||
const portraits = this.manager.filterPortraits({ category: this._category });
|
||||
if (!portraits.length) return;
|
||||
const chosen = portraits[Math.floor(Math.random() * portraits.length)];
|
||||
await this._applyPortrait(chosen.path);
|
||||
});
|
||||
|
||||
html.find('.category-filter').on('change', (ev) => {
|
||||
this._category = ev.currentTarget.value;
|
||||
this.render(true);
|
||||
});
|
||||
}
|
||||
|
||||
async _applyPortrait(path) {
|
||||
const setAvatar = game.settings.get(MODULE_ID, 'setAvatar');
|
||||
const updateData = { 'prototypeToken.texture.src': path };
|
||||
if (setAvatar) updateData.img = path;
|
||||
await this.actor.update(updateData);
|
||||
ui.notifications.info(
|
||||
game.i18n.format('NPC_PORTRAIT_PACK.Notifications.Assigned', { actorName: this.actor.name })
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
async _updateObject(_event, _formData) {
|
||||
// Handled by activateListeners
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const PortraitPickerDialog = buildPickerClass();
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* portrait-manager.mjs
|
||||
* Core portrait loading, filtering, and assignment logic.
|
||||
*
|
||||
* Portrait source : sla_images/portraits/backup_pics/ (799 portraits)
|
||||
* Taxonomy (3 tiers):
|
||||
* type → human | mutant | animal
|
||||
* category → human_female | human_male | human_unknown
|
||||
* mutant_female | mutant_male | mutant_unknown
|
||||
* animal_female | animal_male | animal_unknown
|
||||
* race → original SLA species (ebon, frother, shaktar, etc.) stored as tag
|
||||
*
|
||||
* SLA species → portrait type mapping:
|
||||
* human, ebon, stormer → human
|
||||
* frother → mutant
|
||||
* shaktar, waister, wraith → animal
|
||||
* creature / carrien (actor.type) → animal_unknown
|
||||
* mutant / serial killer archetype → mutant_unknown
|
||||
*/
|
||||
|
||||
import { MODULE_ID } from './settings.mjs';
|
||||
|
||||
const INDEX_PATH = 'sla_images/portraits/backup_pics/index.json';
|
||||
|
||||
// ── Species → broad portrait type ────────────────────────────────────────────
|
||||
|
||||
const SPECIES_TO_TYPE = {
|
||||
// Human-presenting
|
||||
human: 'human', Human: 'human',
|
||||
ebon: 'human', Ebon: 'human',
|
||||
stormer: 'human', Stormer: 'human',
|
||||
// Mutant / altered humans
|
||||
frother: 'mutant', Frother: 'mutant',
|
||||
// Non-human / alien
|
||||
shaktar: 'animal', Shaktar: 'animal',
|
||||
waister: 'animal', Waister: 'animal',
|
||||
wraith: 'animal', Wraith: 'animal',
|
||||
// prefixed variants
|
||||
'sla human': 'human',
|
||||
'sla ebon': 'human',
|
||||
'sla frother': 'mutant',
|
||||
'sla shaktar': 'animal',
|
||||
'sla stormer': 'human',
|
||||
'sla waister': 'animal',
|
||||
'sla wraith': 'animal',
|
||||
};
|
||||
|
||||
// Default images — treated as "no portrait yet"
|
||||
const DEFAULT_IMAGES = new Set([
|
||||
'icons/svg/mystery-man.svg',
|
||||
'icons/svg/mystery-man-black.svg',
|
||||
'systems/sla-mothership/assets/icons/npc.png',
|
||||
'systems/sla-mothership/assets/icons/creature.png',
|
||||
'',
|
||||
]);
|
||||
|
||||
export class PortraitManager {
|
||||
|
||||
constructor() {
|
||||
/** @type {Array<{id,path,category,type,race,gender,tags}>} */
|
||||
this.portraits = [];
|
||||
this._loaded = false;
|
||||
}
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
|
||||
async loadIndex() {
|
||||
const stamp = Date.now();
|
||||
// Try with leading slash first (absolute from server root), then without (relative)
|
||||
const urls = [`/${INDEX_PATH}?v=${stamp}`, `${INDEX_PATH}?v=${stamp}`];
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) continue;
|
||||
const data = await r.json();
|
||||
this.portraits = data.portraits ?? [];
|
||||
this._loaded = true;
|
||||
console.log(`NPC Portrait Pack | Loaded ${this.portraits.length} portraits (v${data.version}) from ${url}`);
|
||||
return;
|
||||
} catch (_) {
|
||||
// try next url
|
||||
}
|
||||
}
|
||||
console.error(`NPC Portrait Pack | Could not load index from ${INDEX_PATH}. Check the file exists in your FoundryVTT Data folder.`);
|
||||
this.portraits = [];
|
||||
}
|
||||
|
||||
// ── Filtering ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter portraits by options.
|
||||
* @param {object} [opts]
|
||||
* @param {string|string[]} [opts.category] e.g. 'human_female', ['animal_male','animal_female']
|
||||
* @param {string|string[]} [opts.type] broad type: 'human' | 'mutant' | 'animal'
|
||||
* @param {string} [opts.gender] 'male' | 'female' — restricts within type
|
||||
* @param {string[]} [opts.tags] extra tag filters
|
||||
*/
|
||||
filterPortraits({ category, type, gender, tags } = {}) {
|
||||
let pool = [...this.portraits];
|
||||
|
||||
// Category filter (most specific)
|
||||
if (category && category !== 'any') {
|
||||
const cats = Array.isArray(category) ? category : [category];
|
||||
pool = pool.filter(p => cats.includes(p.category));
|
||||
}
|
||||
|
||||
// Broad type filter
|
||||
if (type && type !== 'any') {
|
||||
const types = Array.isArray(type) ? type : [type];
|
||||
pool = pool.filter(p => types.includes(p.type));
|
||||
}
|
||||
|
||||
// Gender filter — neutral portraits pass through regardless
|
||||
if (gender && gender !== 'neutral') {
|
||||
pool = pool.filter(p => p.gender === 'neutral' || p.gender === gender);
|
||||
}
|
||||
|
||||
// Tag filters
|
||||
if (tags?.length) {
|
||||
pool = pool.filter(p => tags.every(t => p.tags.includes(t)));
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
// ── Selection ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pick one random portrait, falling back to the full library if the pool is empty.
|
||||
* @returns {{ id, path, category, type, race, gender, tags } | null}
|
||||
*/
|
||||
getRandomPortrait(opts = {}) {
|
||||
let pool = this.filterPortraits(opts);
|
||||
|
||||
if (!pool.length && Object.keys(opts).some(k => opts[k])) {
|
||||
// Widen to same broad type before falling back to everything
|
||||
if (opts.category || opts.gender) {
|
||||
const typeOpts = opts.type ? { type: opts.type } : {};
|
||||
pool = this.filterPortraits(typeOpts);
|
||||
}
|
||||
}
|
||||
if (!pool.length) pool = this.portraits;
|
||||
if (!pool.length) return null;
|
||||
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
// ── Actor → portrait options ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive best portrait filter for an actor based on type, species, gender.
|
||||
* @param {Actor} actor
|
||||
* @returns {{ category?: string|string[], type?: string, gender?: string } | null}
|
||||
*/
|
||||
portraitOptsForActor(actor) {
|
||||
if (actor.type === 'ship') return null;
|
||||
|
||||
// Creature-type actors (carrien, etc.) → animal
|
||||
if (actor.type === 'creature') {
|
||||
return { category: 'animal_unknown' };
|
||||
}
|
||||
|
||||
const speciesRaw = (
|
||||
actor.system?.sla?.species?.value ??
|
||||
actor.system?.species?.value ?? ''
|
||||
).toLowerCase().trim();
|
||||
|
||||
const genderRaw = (
|
||||
actor.system?.sla?.gender?.value ??
|
||||
actor.system?.gender?.value ?? ''
|
||||
).toLowerCase().trim();
|
||||
|
||||
const broadType = SPECIES_TO_TYPE[speciesRaw] ?? 'human';
|
||||
const gender = genderRaw === 'female' ? 'female'
|
||||
: genderRaw === 'male' ? 'male'
|
||||
: null;
|
||||
|
||||
if (gender) {
|
||||
return { category: `${broadType}_${gender}`, gender };
|
||||
}
|
||||
// No gender — use both gendered pools + unknown for this type
|
||||
return {
|
||||
category: [`${broadType}_male`, `${broadType}_female`, `${broadType}_unknown`],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Assignment ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assign a portrait to an actor, respecting module settings.
|
||||
* @param {Actor} actor
|
||||
* @param {object} [overrideOpts] explicit filter opts (from generator / picker)
|
||||
* @returns {Promise<object|null>} chosen portrait entry
|
||||
*/
|
||||
async assignPortraitToActor(actor, overrideOpts = null) {
|
||||
if (!actor) return null;
|
||||
|
||||
if (game.settings.get(MODULE_ID, 'onlyIfDefaultImage')) {
|
||||
const src = actor.prototypeToken?.texture?.src ?? actor.img ?? '';
|
||||
// Skip only if the actor already has one of OUR portraits assigned.
|
||||
// Any other image (system defaults, blank, mystery-man, etc.) counts as "no portrait yet".
|
||||
const alreadyHasOurPortrait = src && src.includes('backup_pics');
|
||||
if (alreadyHasOurPortrait) return null;
|
||||
}
|
||||
|
||||
let filterOpts;
|
||||
if (overrideOpts) {
|
||||
filterOpts = overrideOpts;
|
||||
} else if (game.settings.get(MODULE_ID, 'matchSpecies')) {
|
||||
filterOpts = this.portraitOptsForActor(actor);
|
||||
if (!filterOpts) return null;
|
||||
} else {
|
||||
const cat = game.settings.get(MODULE_ID, 'defaultCategory');
|
||||
filterOpts = cat === 'any' ? {} : { category: cat };
|
||||
}
|
||||
|
||||
const portrait = this.getRandomPortrait(filterOpts ?? {});
|
||||
if (!portrait) return null;
|
||||
|
||||
const setAvatar = game.settings.get(MODULE_ID, 'setAvatar');
|
||||
const updateData = { 'prototypeToken.texture.src': portrait.path };
|
||||
if (setAvatar) updateData.img = portrait.path;
|
||||
|
||||
await actor.update(updateData);
|
||||
console.log(`NPC Portrait Pack | "${portrait.path}" → "${actor.name}" [${portrait.category}]`);
|
||||
return portrait;
|
||||
}
|
||||
|
||||
// ── Category list for UI ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns sorted array of { key, label, count } for all categories.
|
||||
*/
|
||||
getCategoryList() {
|
||||
const counts = {};
|
||||
for (const p of this.portraits) {
|
||||
counts[p.category] = (counts[p.category] ?? 0) + 1;
|
||||
}
|
||||
// Totals per broad type
|
||||
const typeTotals = { human: 0, mutant: 0, animal: 0 };
|
||||
for (const p of this.portraits) typeTotals[p.type] = (typeTotals[p.type] ?? 0) + 1;
|
||||
|
||||
const LABELS = {
|
||||
human_female: `Human — Female`,
|
||||
human_male: `Human — Male`,
|
||||
human_unknown: `Human — Unknown gender`,
|
||||
mutant_female: `Mutant — Female`,
|
||||
mutant_male: `Mutant — Male`,
|
||||
mutant_unknown: `Mutant — Unknown gender`,
|
||||
animal_female: `Animal / Alien — Female`,
|
||||
animal_male: `Animal / Alien — Male`,
|
||||
animal_unknown: `Animal / Alien — Unknown`,
|
||||
};
|
||||
|
||||
return Object.entries(counts)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, count]) => ({ key, label: LABELS[key] ?? key, count }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// settings.mjs — NPC Portrait Pack module settings
|
||||
|
||||
export const MODULE_ID = 'npc-portrait-pack';
|
||||
|
||||
export function registerSettings() {
|
||||
|
||||
// Auto-assign on NPC creation
|
||||
game.settings.register(MODULE_ID, 'autoAssign', {
|
||||
name: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.AutoAssign.Name'),
|
||||
hint: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.AutoAssign.Hint'),
|
||||
scope: 'world',
|
||||
config: true,
|
||||
type: Boolean,
|
||||
default: true
|
||||
});
|
||||
|
||||
// Match species/race when auto-assigning
|
||||
game.settings.register(MODULE_ID, 'matchSpecies', {
|
||||
name: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.MatchSpecies.Name'),
|
||||
hint: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.MatchSpecies.Hint'),
|
||||
scope: 'world',
|
||||
config: true,
|
||||
type: Boolean,
|
||||
default: true
|
||||
});
|
||||
|
||||
// Only assign if the actor currently has the default token/avatar image
|
||||
game.settings.register(MODULE_ID, 'onlyIfDefaultImage', {
|
||||
name: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.OnlyIfDefaultImage.Name'),
|
||||
hint: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.OnlyIfDefaultImage.Hint'),
|
||||
scope: 'world',
|
||||
config: true,
|
||||
type: Boolean,
|
||||
default: true
|
||||
});
|
||||
|
||||
// Set actor avatar image (not just token)
|
||||
game.settings.register(MODULE_ID, 'setAvatar', {
|
||||
name: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.SetAvatar.Name'),
|
||||
hint: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.SetAvatar.Hint'),
|
||||
scope: 'world',
|
||||
config: true,
|
||||
type: Boolean,
|
||||
default: true
|
||||
});
|
||||
|
||||
// Default category fallback when species matching is off
|
||||
game.settings.register(MODULE_ID, 'defaultCategory', {
|
||||
name: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.DefaultCategory.Name'),
|
||||
hint: game.i18n.localize('NPC_PORTRAIT_PACK.Settings.DefaultCategory.Hint'),
|
||||
scope: 'world',
|
||||
config: true,
|
||||
type: String,
|
||||
choices: {
|
||||
'any': 'Any — all 799 portraits',
|
||||
'human_female': 'Human — Female (97)',
|
||||
'human_male': 'Human — Male (98)',
|
||||
'human_unknown': 'Human — Unknown gender (367)',
|
||||
'mutant_female': 'Mutant — Female (41)',
|
||||
'mutant_male': 'Mutant — Male (41)',
|
||||
'mutant_unknown': 'Mutant — Unknown gender (57)',
|
||||
'animal_female': 'Animal / Alien — Female (38)',
|
||||
'animal_male': 'Animal / Alien — Male (36)',
|
||||
'animal_unknown': 'Animal / Alien — Unknown (24)',
|
||||
},
|
||||
default: 'any'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user