chore: initial deploy — SLA Industries Foundry VTT

Co-authored-by: slaguru666 <timevans666@gmail.com>
This commit is contained in:
2026-03-31 17:00:15 +01:00
commit 45cf504bfb
704 changed files with 8089 additions and 0 deletions
+83
View File
@@ -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); });
+834
View File
@@ -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}`);
+998
View File
@@ -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 };
+138
View File
@@ -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);
});
+177
View File
@@ -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();
+259
View File
@@ -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 }));
}
}
+68
View File
@@ -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'
});
}