feat: Group NPC — folders, NPC icons, weapon/ability icons

Folder structure:
- Actors sidebar: Generated NPCs > [Type] (e.g. Generated NPCs > Carrion)
- Folders created automatically if they do not already exist
- Each type gets its own colour-coded subfolder

NPC actor portrait:
- Street Gang / Serial Killer: mystery-man.svg
- Carrion / Serial Killer:     skull.svg
- Monstaret:                   hazard.svg
- Machine:                     daze.svg

Weapon item icons mapped by name:
- Bite/claw/natural:   unarmed-punch-fist.webp
- Acid/spit:           aura.svg
- Rifle/minigun:       rifle-bayonet.webp
- Shotgun:             gun-blunderbuss.webp
- Pistol/SMG/ranged:   gun-pistol-flintlock-metal.webp
- Blunt melee:         club-simple-black.webp
- Blade/knife/sharp:   dagger-straight-sharp.webp

Armor icons: light (<3) vest-simple-leather, heavy (3+) breastplate

Ability icons mapped by keyword (stealth=cowled, acid=aura,
fire=fire, stun/emp=daze, bleed=blood, heal=regen, else=lightning)
This commit is contained in:
2026-06-03 18:40:06 +01:00
parent 2713db753f
commit ef5ea36e4c
2 changed files with 117 additions and 18 deletions
+116 -17
View File
@@ -6118,6 +6118,96 @@ class SLAGroupNPCTool extends Application {
};
}
// ── Icon helpers ─────────────────────────────────────────────────────────
/** Portrait icon for the NPC actor based on type */
_getNPCIcon(typeKey) {
return {
streetgang: 'icons/svg/mystery-man.svg',
carrion: 'icons/svg/skull.svg',
serialkiller: 'icons/svg/skull.svg',
monstaret: 'icons/svg/hazard.svg',
machine: 'icons/svg/daze.svg',
}[typeKey] ?? 'icons/svg/mystery-man.svg';
}
/** Choose weapon item icon based on name / range */
_getWeaponIcon(weapon) {
const n = (weapon.name || '').toLowerCase();
const r = (weapon.range || '').toLowerCase();
// Natural
if (n.includes('bite') || n.includes('mandible') || n.includes('screech'))
return 'icons/skills/melee/unarmed-punch-fist.webp';
if (n.includes('claw') || n.includes('rend') || n.includes('constrict') || n.includes('tentacle') || n.includes('grip') || n.includes('fist'))
return 'icons/skills/melee/unarmed-punch-fist.webp';
if (n.includes('spit') || n.includes('acid'))
return 'icons/svg/aura.svg';
// Ranged
if (n.includes('rifle') || n.includes('minigun') || n.includes('sniper'))
return 'icons/weapons/guns/rifle-bayonet.webp';
if (n.includes('shotgun') || n.includes('blunderbuss'))
return 'icons/weapons/guns/gun-blunderbuss.webp';
if (n.includes('pistol') || n.includes('smg') || n.includes('dart') || n.includes('crossbow'))
return 'icons/weapons/guns/gun-pistol-flintlock-metal.webp';
if (n.includes('flamethrower') || n.includes('laser') || n.includes('taser') || n.includes('shock'))
return 'icons/weapons/guns/gun-pistol-flintlock-metal.webp';
// Blunt melee
if (n.includes('pipe') || n.includes('bat') || n.includes('baton') || n.includes('chain') || n.includes('crowbar') || n.includes('knuckle') || n.includes('wrench') || n.includes('fist'))
return 'icons/weapons/clubs/club-simple-black.webp';
// Sharp melee
if (n.includes('knife') || n.includes('blade') || n.includes('scalpel') || n.includes('stiletto') || n.includes('cleaver') || n.includes('machete') || n.includes('hatchet') || n.includes('axe') || n.includes('wire') || n.includes('saw'))
return 'icons/weapons/daggers/dagger-straight-sharp.webp';
// Fallback by range
if (r && r !== 'engaged') return 'icons/weapons/guns/gun-pistol-flintlock-metal.webp';
return 'icons/weapons/daggers/dagger-straight-sharp.webp';
}
/** Choose ability/specialty icon based on text */
_getAbilityIcon(text) {
const t = (text || '').toLowerCase();
if (t.includes('stealth') || t.includes('camouflage') || t.includes('hidden') || t.includes('ambush'))
return 'icons/svg/cowled.svg';
if (t.includes('acid') || t.includes('poison') || t.includes('contagion') || t.includes('venom'))
return 'icons/svg/aura.svg';
if (t.includes('fire') || t.includes('burn') || t.includes('flame'))
return 'icons/svg/fire.svg';
if (t.includes('stun') || t.includes('emp') || t.includes('shock') || t.includes('electric'))
return 'icons/svg/daze.svg';
if (t.includes('bleed') || t.includes('rend') || t.includes('gore'))
return 'icons/svg/blood.svg';
if (t.includes('heal') || t.includes('repair') || t.includes('recover') || t.includes('diagnos'))
return 'icons/svg/regen.svg';
return 'icons/svg/lightning.svg';
}
/** Find or create an Actor folder by name, optionally under a parent */
async _ensureFolder(name, parentId = null) {
let folder = (game.folders ?? []).find(f =>
f.type === 'Actor' && f.name === name && (f.folder?.id ?? null) === parentId
);
if (!folder) {
try {
const colors = {
'Generated NPCs': '#442211',
'Street Gang': '#996611',
'Carrion': '#883311',
'Serial Killer': '#661133',
'Monstaret': '#224411',
'Machine': '#112244',
};
folder = await Folder.create({
name, type: 'Actor',
folder: parentId,
sorting: 'a',
color: colors[name] ?? '#333333'
});
} catch(e) {
console.warn(`Zero Engine | Could not create folder "${name}":`, e);
}
}
return folder ?? null;
}
// ── Save in-progress edits before re-render ───────────────────────────────
_saveState() {
const root = this._root;
@@ -6399,16 +6489,22 @@ class SLAGroupNPCTool extends Application {
})
);
// Create as World Actors — with embedded weapon, armor, and specialty items
// Create as World Actors — folder + icons + embedded items
root.querySelector('.gnpc-create-btn')?.addEventListener('click', async () => {
if (!game.user?.isGM) return;
this._saveState();
// Get/create folder hierarchy: Generated NPCs > Type
const rootFolder = await this._ensureFolder('Generated NPCs');
const typeLabel = SLA_NPC_GROUP_ARCHETYPES[this._selectedType]?.label ?? 'NPCs';
const typeFolder = await this._ensureFolder(typeLabel, rootFolder?.id ?? null);
const folderId = typeFolder?.id ?? rootFolder?.id ?? null;
let created = 0;
for (const npc of this._npcs) {
try {
// Formatted biography
const bio = [];
bio.push(`Type: ${npc.typeLabel} | Threat: ${npc.threat}`);
// Biography
const bio = [`Type: ${npc.typeLabel} | Threat: ${npc.threat}`];
if (npc.armorDesc && npc.armorDesc !== 'None') bio.push(`Armour: ${npc.armorDesc}`);
const allWep = [...(npc.naturalWeapons ?? []), ...(npc.weapons ?? [])];
if (allWep.length) {
@@ -6421,16 +6517,18 @@ class SLAGroupNPCTool extends Application {
}
if (npc.notes) bio.push(`\nNotes:\n ${npc.notes}`);
// Create base actor
// Create actor with portrait icon
const actorDoc = await Actor.create({
name: npc.name, type: 'npc',
img: this._getNPCIcon(npc.typeKey),
folder: folderId,
system: {
biography: bio.join('\n'),
threat: npc.threat,
threat: npc.threat,
attributes: { strength: npc.str, agility: npc.agi, wits: npc.wit, empathy: npc.emp },
health: { value: npc.hp, max: npc.hpMax },
armor: npc.armor,
damage: npc.damage
health: { value: npc.hp, max: npc.hpMax },
armor: npc.armor,
damage: npc.damage
}
});
if (!actorDoc) continue;
@@ -6438,15 +6536,13 @@ class SLAGroupNPCTool extends Application {
// Build embedded items
const embeds = [];
// Weapon items (natural + conventional/integrated)
// Weapon items
for (const w of allWep) {
const isRanged = w.range && w.range !== 'Engaged';
const rofMatch = w.note?.match(/ROF\s*(\d+)/i);
embeds.push({
name: w.name, type: 'weapon',
img: isRanged
? 'icons/weapons/guns/gun-pistol-flintlock-metal.webp'
: 'icons/weapons/daggers/dagger-straight-sharp.webp',
img: this._getWeaponIcon(w),
system: {
description: w.note || '', weaponType: w.name,
category: isRanged ? 'ranged' : 'melee',
@@ -6459,12 +6555,15 @@ class SLAGroupNPCTool extends Application {
});
}
// Armor item (skip Built-in; machines use their armor stat directly)
// Armor item
if (npc.armor > 0 && npc.armorDesc && npc.armorDesc !== 'None' && !npc.armorDesc.startsWith('Built-in')) {
const armorName = npc.armorDesc.replace(/\s*\(ARM \d+\)/, '').trim() || 'Armour';
const heavyArmor = npc.armor >= 3;
embeds.push({
name: armorName, type: 'armor',
img: 'icons/equipment/chest/vest-simple-leather-brown.webp',
img: heavyArmor
? 'icons/equipment/chest/breastplate-banded-steel.webp'
: 'icons/equipment/chest/vest-simple-leather-brown.webp',
system: {
description: npc.armorDesc, armorDice: npc.armor, armorAuto: 0,
armorRating: npc.armor, statMod: 0, skillMod: 0, statModTarget: '',
@@ -6478,7 +6577,7 @@ class SLAGroupNPCTool extends Application {
const title = ability.match(/^([^—\-]+)/)?.[1]?.trim() ?? ability.slice(0, 30);
embeds.push({
name: title, type: 'specialty',
img: 'icons/skills/social/intimidation-impressing.webp',
img: this._getAbilityIcon(ability),
system: {
description: ability, effects: ability, category: 'general',
package: npc.typeLabel, prerequisites: '', isActive: true,
@@ -6496,7 +6595,7 @@ class SLAGroupNPCTool extends Application {
console.error(`Zero Engine | Group NPC: failed to create "${npc.name}"`, err);
}
}
ui.notifications.info(`Created ${created} NPC actor${created !== 1 ? 's' : ''} in the world.`);
ui.notifications.info(`Created ${created} NPC${created !== 1 ? 's' : ''} in folder: Generated NPCs / ${typeLabel}`);
});
}
}