260 lines
9.6 KiB
JavaScript
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 }));
|
|
}
|
|
}
|