Files
npc-portrait-pack/scripts/portrait-manager.mjs
T
2026-03-31 17:00:15 +01:00

260 lines
9.6 KiB
JavaScript

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