diff --git a/styles/zero-engine.css b/styles/zero-engine.css index 023e37e..069e614 100644 --- a/styles/zero-engine.css +++ b/styles/zero-engine.css @@ -4386,3 +4386,137 @@ body > .ze-pause-root .ze-sat-inner { background: rgba(255,200,0,0.35); box-shadow: 0 0 10px rgba(255,200,0,0.3); } + +/* ══════════════════════════════════════════════════════════════════════════════ + GROUP NPC GENERATOR + ══════════════════════════════════════════════════════════════════════════════ */ +.group-npc-tool .window-content { padding: 0; overflow: hidden; } +.group-npc-tool .gnpc-wrap { + display: flex; flex-direction: column; height: 100%; background: #1a1a1a; +} +/* Toolbar */ +.group-npc-tool .gnpc-toolbar { + padding: 8px 10px 6px; border-bottom: 1px solid #2a2a2a; + display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; +} +.group-npc-tool .gnpc-type-row { display: flex; gap: 5px; flex-wrap: wrap; } +.group-npc-tool .gnpc-type-btn { + padding: 4px 12px; font-size: 12px; font-weight: bold; border-radius: 4px; + border: 1px solid; cursor: pointer; background: transparent; + transition: all 0.15s; white-space: nowrap; +} +.group-npc-tool .gnpc-type-btn:hover { opacity: 0.85; } +.group-npc-tool .gnpc-action-row { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; +} +.group-npc-tool .gnpc-count-lbl { + display: flex; align-items: center; gap: 5px; font-size: 12px; color: #aaa; +} +.group-npc-tool .gnpc-count-sel { + background: rgba(255,255,255,0.08); border: 1px solid #444; + color: #eee; border-radius: 3px; padding: 2px 4px; font-size: 12px; +} +.group-npc-tool .gnpc-gen-btn { background:rgba(255,200,0,0.15); border:1px solid #ffcc00; color:#ffcc00; border-radius:3px; padding:3px 10px; font-size:12px; font-weight:bold; cursor:pointer; } +.group-npc-tool .gnpc-add-btn { background:rgba(0,180,100,0.12); border:1px solid #00bb66; color:#00bb66; border-radius:3px; padding:3px 10px; font-size:12px; cursor:pointer; } +.group-npc-tool .gnpc-clr-btn { background:rgba(200,50,50,0.1); border:1px solid #884444; color:#bb6666; border-radius:3px; padding:3px 10px; font-size:12px; cursor:pointer; margin-left:auto; } +.group-npc-tool .gnpc-gen-btn:hover { background:rgba(255,200,0,0.3); } +.group-npc-tool .gnpc-add-btn:hover { background:rgba(0,180,100,0.25); } +.group-npc-tool .gnpc-clr-btn:hover { background:rgba(200,50,50,0.25); } +/* Card list */ +.group-npc-tool .gnpc-list { + flex: 1; overflow-y: auto; padding: 8px 10px; + display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 8px; align-content: start; +} +.group-npc-tool .gnpc-empty { + text-align: center; padding: 40px 20px; color: #555; + font-style: italic; grid-column: 1 / -1; +} +/* NPC card */ +.group-npc-tool .gnpc-card { + background: rgba(255,255,255,0.03); border-radius: 5px; + padding: 8px 10px; display: flex; flex-direction: column; gap: 5px; + transition: opacity 0.2s; +} +.group-npc-tool .gnpc-card.gnpc-defeated { opacity: 0.4; } +.group-npc-tool .gnpc-card-top { + display: flex; align-items: center; gap: 6px; +} +.group-npc-tool .gnpc-name-input { + flex: 1; min-width: 0; background: rgba(255,255,255,0.06); + border: 1px solid #333; border-radius: 3px; color: #eee; + padding: 3px 6px; font-size: 13px; font-weight: bold; +} +.group-npc-tool .gnpc-name-input:focus { border-color: #555; outline: none; } +.group-npc-tool .gnpc-type-tag { + font-size: 10px; padding: 2px 7px; border-radius: 10px; border: 1px solid; + white-space: nowrap; flex-shrink: 0; +} +.group-npc-tool .gnpc-threat-tag { font-size: 11px; color: #666; white-space: nowrap; flex-shrink: 0; } +.group-npc-tool .gnpc-card-btns { display: flex; gap: 3px; flex-shrink: 0; } +.group-npc-tool .gnpc-btn { + background: transparent; border: 1px solid #333; border-radius: 3px; + color: #888; padding: 2px 5px; cursor: pointer; font-size: 11px; transition: all 0.15s; +} +.group-npc-tool .gnpc-btn:hover { background: rgba(255,255,255,0.08); color: #ccc; } +.group-npc-tool .gnpc-remove-btn:hover { border-color: #cc4444; color: #cc4444; } +/* Stats row */ +.group-npc-tool .gnpc-card-stats { + display: flex; align-items: center; gap: 7px; flex-wrap: wrap; font-size: 11px; +} +.group-npc-tool .gnpc-stat-lbl { + display: flex; align-items: center; gap: 3px; color: #777; +} +.group-npc-tool .gnpc-stat-lbl-plain { color: #777; font-size: 11px; } +.group-npc-tool .gnpc-stat-input { + width: 34px; text-align: center; background: rgba(255,255,255,0.07); + border: 1px solid #2a2a2a; border-radius: 3px; color: #ddd; + padding: 2px 2px; font-size: 12px; +} +.group-npc-tool .gnpc-sep { color: #333; } +.group-npc-tool .gnpc-hp-group { + display: flex; align-items: center; gap: 3px; +} +.group-npc-tool .gnpc-hp-val { width: 30px; color: #88eeaa; } +.group-npc-tool .gnpc-hp-max { width: 30px; color: #aaa; } +.group-npc-tool .gnpc-hp-sep { color: #444; font-size: 11px; } +.group-npc-tool .gnpc-hp-btn { + background: rgba(255,255,255,0.06); border: 1px solid #333; border-radius: 3px; + color: #888; padding: 0 4px; cursor: pointer; font-size: 13px; line-height: 1.4; + flex-shrink: 0; transition: all 0.1s; +} +.group-npc-tool .gnpc-hp-btn:hover { background: rgba(255,255,255,0.15); color: #eee; } +.group-npc-tool .gnpc-hp-bar-wrap { + width: 50px; height: 5px; background: #222; border-radius: 3px; + overflow: hidden; flex-shrink: 0; +} +.group-npc-tool .gnpc-hp-bar { height: 100%; border-radius: 3px; transition: width 0.2s, background 0.3s; } +/* Abilities */ +.group-npc-tool .gnpc-abilities { display: flex; flex-direction: column; gap: 1px; } +.group-npc-tool .gnpc-ability { font-size: 10.5px; color: #888; padding: 0 2px; line-height: 1.4; } +.group-npc-tool .gnpc-ability i { color: #555; margin-right: 3px; } +/* Notes */ +.group-npc-tool .gnpc-notes-input { + width: 100%; background: rgba(255,255,255,0.03); border: 1px solid #222; + border-radius: 3px; color: #888; font-size: 11px; padding: 3px 6px; + resize: vertical; box-sizing: border-box; font-family: inherit; +} +.group-npc-tool .gnpc-notes-input:focus { border-color: #444; outline: none; color: #bbb; } +/* Footer */ +.group-npc-tool .gnpc-footer { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 10px; border-top: 1px solid #2a2a2a; flex-shrink: 0; +} +.group-npc-tool .gnpc-tally { font-size: 12px; color: #666; } +.group-npc-tool .gnpc-footer-btns { display: flex; gap: 8px; } +.group-npc-tool .gnpc-reset-hp-btn { + padding: 4px 12px; font-size: 11px; background: rgba(255,100,100,0.1); + border: 1px solid #884444; color: #cc8888; border-radius: 3px; cursor: pointer; +} +.group-npc-tool .gnpc-reset-hp-btn:hover { background: rgba(255,100,100,0.2); } +.group-npc-tool .gnpc-create-btn { + padding: 4px 12px; font-size: 11px; font-weight: bold; + background: rgba(255,200,0,0.15); border: 1px solid #ffcc00; + color: #ffcc00; border-radius: 3px; cursor: pointer; +} +.group-npc-tool .gnpc-create-btn:hover { background: rgba(255,200,0,0.3); } diff --git a/system.json b/system.json index 4ddbd76..0ce9e3a 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "zero-engine", "title": "Zero Engine (SLA Industries)", "description": "A basic game system for SLA Industries using the Zero Engine rules.", - "version": "1.5.1", + "version": "1.5.2", "compatibility": { "minimum": 13, "verified": "14.359" diff --git a/zero-engine.mjs b/zero-engine.mjs index ba7b0c3..c734b53 100644 --- a/zero-engine.mjs +++ b/zero-engine.mjs @@ -5710,6 +5710,420 @@ class SLAGMFinanceTool extends Application { } } +// ══════════════════════════════════════════════════════════════════════════════ +// SLA GROUP NPC GENERATOR +// GM tool: quickly generate and track groups of Carrions, Serial Killers, +// Monstarets or Machines. Compact editable stat cards, HP tracking, +// reroll per card, and one-click export to World actors. +// ══════════════════════════════════════════════════════════════════════════════ + +const SLA_NPC_GROUP_ARCHETYPES = { + carrion: { + label: 'Carrion', color: '#cc4422', icon: 'fas fa-biohazard', + nameParts: ['Bloated', 'Feral', 'Stalking', 'Gnawing', 'Lurching', 'Rabid', 'Rotting', 'Hulking'], + nameBase: ['Carrion', 'Carrion', 'Carrien', 'Feeder', 'Gnawer'], + stats: { str:[2,4], agi:[1,2], wit:[1,1], emp:[1,1] }, + health:[3,6], armor:[0,1], damage:[2,3], threat:[1,2], + abilities:[ + 'Pack Aggression — +1 die per Carrion adjacent to the same target', + 'Bite — 2 damage, ignores 1 armour point', + 'Mindless — immune to Panic and social effects', + 'Hard to Kill — rolls Physical Critical even at 0 HP (once per fight)', + 'Swarm — 3+ attack same target simultaneously: +2 damage total', + ] + }, + serialkiller: { + label: 'Serial Killer', color: '#882244', icon: 'fas fa-skull', + nameParts: ['The', 'The', 'The', ''], + nameBase: ['Butcher','Flenser','Ghost','Surgeon','Reaper','Cutter','Shadow','Wraith','Stalker'], + stats: { str:[2,4], agi:[2,4], wit:[2,3], emp:[1,2] }, + health:[3,5], armor:[0,2], damage:[3,4], threat:[3,4], + abilities:[ + 'Signature Weapon — +1 die with preferred weapon type', + 'Predator Focus — ignores cover when attacking chosen target', + 'Surprise Strike — first attack each combat deals +2 damage', + 'Psychotic Endurance — ignores one level of injury penalties', + 'Brutal Efficiency — 3+ successes on attack: target is Stunned', + 'Stealth — 3 free Stealth dice when ambushing from concealment', + ] + }, + monstaret: { + label: 'Monstaret', color: '#226622', icon: 'fas fa-dragon', + nameParts: ['Alpha','Juvenile','Mature','Mutant','Corrupted','Ancient','Feral'], + nameBase: ['Monstaret','Rend-Beast','Fang-Beast','Pit-Thing','Lurker','Crawler','Howler'], + stats: { str:[4,6], agi:[2,3], wit:[1,1], emp:[1,1] }, + health:[6,12], armor:[1,4], damage:[3,5], threat:[3,5], + abilities:[ + 'Natural Weapons — claws/fangs; always counts as armed', + 'Pack Hunter — flanking bonus: +1 die when 2+ present', + 'Toughened Hide — ignore first 1 damage from each attack', + 'Frenzy — at ≤ half HP, +2 damage on all attacks', + 'Leap — charge-attack targets up to 10m away', + 'Predatory Instinct — always acts in initiative phase 1', + ] + }, + machine: { + label: 'Machine', color: '#334488', icon: 'fas fa-robot', + nameParts: ['MK-','Unit-','Model-','DES-','SYS-'], + nameBase: ['7','12','19X','44','3B','Alpha','Delta','Sigma','Rho'], + stats: { str:[3,5], agi:[2,3], wit:[2,4], emp:[0,0] }, + health:[5,10], armor:[3,6], damage:[3,5], threat:[3,5], + abilities:[ + 'Targeting — re-roll one miss die per attack', + 'Weapon Array — attack 2 targets in one action (−1 die each)', + 'Armoured Shell — natural armour 3; stacks before equipped armour', + 'Diagnostic — regain 2 HP at start of turn if not attacked that round', + 'Overload — on death, 3 damage to all within 5m', + 'EMP Resistant — immune to electrical and shock effects', + ] + } +}; + +class SLAGroupNPCTool extends Application { + constructor(...args) { + super(...args); + this._npcs = []; + this._selectedType = 'carrion'; + this._count = 4; + this._root = null; + } + + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: 'sla-group-npc-tool', + title: 'Group NPC Generator', + width: 860, + height: 600, + resizable: true, + classes: ['zero-engine', 'group-npc-tool'] + }); + } + + // ── Utilities ───────────────────────────────────────────────────────────── + _rng(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } + _pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } + _hexRgb(hex) { + const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return r ? `${parseInt(r[1],16)},${parseInt(r[2],16)},${parseInt(r[3],16)}` : '128,128,128'; + } + _esc(s) { return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/ Math.random() - 0.5); + return { + id: foundry.utils.randomID(), typeKey, + typeLabel: arch.label, color: arch.color, icon: arch.icon, + name, str, agi, wit, emp, + hp: hpMax, hpMax, armor, damage, threat, + abilities: pool.slice(0, 2), + notes: '', defeated: false + }; + } + + // ── Save in-progress edits before re-render ─────────────────────────────── + _saveState() { + const root = this._root; + if (!root) return; + root.querySelectorAll('.gnpc-name-input').forEach(el => { + const n = this._npcs.find(x => x.id === el.dataset.npcId); + if (n) n.name = el.value; + }); + root.querySelectorAll('.gnpc-stat-input').forEach(el => { + const n = this._npcs.find(x => x.id === el.dataset.npcId); + if (n && el.dataset.field) n[el.dataset.field] = parseInt(el.value) || 0; + }); + root.querySelectorAll('.gnpc-notes-input').forEach(el => { + const n = this._npcs.find(x => x.id === el.dataset.npcId); + if (n) n.notes = el.value; + }); + } + + // ── Build one NPC card ──────────────────────────────────────────────────── + _buildCard(npc) { + const rgb = this._hexRgb(npc.color); + const hpPct = npc.hpMax > 0 ? Math.max(0, Math.min(100, (npc.hp / npc.hpMax) * 100)) : 0; + const hpColor = hpPct > 60 ? '#44cc66' : hpPct > 30 ? '#ffaa00' : '#cc3333'; + const defeated = npc.defeated ? ' gnpc-defeated' : ''; + const abils = npc.abilities.map(a => + `
${this._esc(a)}
` + ).join(''); + return ` +
+
+ + + ${npc.typeLabel} + + T${npc.threat} +
+ + + +
+
+
+ + + + + | +
+ HP + + + / + + +
+
+ | + + +
+
${abils}
+ +
`; + } + + // ── Full render ─────────────────────────────────────────────────────────── + async _renderInner(_data) { + const types = Object.keys(SLA_NPC_GROUP_ARCHETYPES); + + const typeBtns = types.map(k => { + const a = SLA_NPC_GROUP_ARCHETYPES[k]; + const act = k === this._selectedType; + const rgb = this._hexRgb(a.color); + return ``; + }).join(''); + + const countOpts = [1,2,3,4,5,6,8,10].map(n => + `` + ).join(''); + + const alive = this._npcs.filter(n => !n.defeated).length; + const defeated = this._npcs.filter(n => n.defeated).length; + const cards = this._npcs.map(n => this._buildCard(n)).join(''); + const empty = `
Choose a type and press Generate to create NPCs.
`; + + const footer = this._npcs.length ? ` + ` : ''; + + return $(` +
+
+
${typeBtns}
+
+ + + + +
+
+
${this._npcs.length ? cards : empty}
+ ${footer} +
`); + } + + // ── Listeners ───────────────────────────────────────────────────────────── + activateListeners(html) { + super.activateListeners(html); + const root = html instanceof jQuery ? html[0] : html; + this._root = root; + + // Type tab selection + root.querySelectorAll('.gnpc-type-btn').forEach(btn => + btn.addEventListener('click', () => { + this._saveState(); + this._selectedType = btn.dataset.type; + this.render(false); + }) + ); + + // Count selector + root.querySelector('.gnpc-count-sel')?.addEventListener('change', e => { + this._count = parseInt(e.target.value) || 4; + }); + + // Generate (replace all) + root.querySelector('.gnpc-gen-btn')?.addEventListener('click', () => { + this._npcs = []; + for (let i = 0; i < this._count; i++) { + const n = this._generateOne(this._selectedType); + if (n) this._npcs.push(n); + } + this.render(false); + }); + + // Add more (append) + root.querySelector('.gnpc-add-btn')?.addEventListener('click', () => { + this._saveState(); + for (let i = 0; i < this._count; i++) { + const n = this._generateOne(this._selectedType); + if (n) this._npcs.push(n); + } + this.render(false); + }); + + // Clear all + root.querySelector('.gnpc-clr-btn')?.addEventListener('click', () => { + this._npcs = []; + this.render(false); + }); + + // Defeat toggle + root.querySelectorAll('.gnpc-defeat-btn').forEach(btn => + btn.addEventListener('click', () => { + this._saveState(); + const n = this._npcs.find(x => x.id === btn.dataset.npcId); + if (n) { n.defeated = !n.defeated; this.render(false); } + }) + ); + + // Reroll one + root.querySelectorAll('.gnpc-reroll-btn').forEach(btn => + btn.addEventListener('click', () => { + this._saveState(); + const idx = this._npcs.findIndex(x => x.id === btn.dataset.npcId); + if (idx < 0) return; + const fresh = this._generateOne(this._npcs[idx].typeKey); + if (fresh) { fresh.notes = this._npcs[idx].notes; this._npcs[idx] = fresh; } + this.render(false); + }) + ); + + // Remove one + root.querySelectorAll('.gnpc-remove-btn').forEach(btn => + btn.addEventListener('click', () => { + this._saveState(); + this._npcs = this._npcs.filter(x => x.id !== btn.dataset.npcId); + this.render(false); + }) + ); + + // HP ± buttons (no full re-render — update DOM directly) + root.querySelectorAll('.gnpc-hp-down').forEach(btn => + btn.addEventListener('click', () => { + const n = this._npcs.find(x => x.id === btn.dataset.npcId); + if (!n) return; + n.hp = Math.max(0, n.hp - 1); + const card = root.querySelector(`.gnpc-card[data-npc-id="${n.id}"]`); + if (card) { + card.querySelector('.gnpc-hp-val').value = n.hp; + const pct = n.hpMax > 0 ? Math.max(0, (n.hp / n.hpMax) * 100) : 0; + const color = pct > 60 ? '#44cc66' : pct > 30 ? '#ffaa00' : '#cc3333'; + const bar = card.querySelector('.gnpc-hp-bar'); + if (bar) { bar.style.width = `${pct}%`; bar.style.background = color; } + } + }) + ); + root.querySelectorAll('.gnpc-hp-up').forEach(btn => + btn.addEventListener('click', () => { + const n = this._npcs.find(x => x.id === btn.dataset.npcId); + if (!n) return; + n.hp = Math.min(n.hpMax, n.hp + 1); + const card = root.querySelector(`.gnpc-card[data-npc-id="${n.id}"]`); + if (card) { + card.querySelector('.gnpc-hp-val').value = n.hp; + const pct = n.hpMax > 0 ? Math.max(0, (n.hp / n.hpMax) * 100) : 0; + const color = pct > 60 ? '#44cc66' : pct > 30 ? '#ffaa00' : '#cc3333'; + const bar = card.querySelector('.gnpc-hp-bar'); + if (bar) { bar.style.width = `${pct}%`; bar.style.background = color; } + } + }) + ); + + // Reset all HP to max + root.querySelector('.gnpc-reset-hp-btn')?.addEventListener('click', () => { + this._saveState(); + this._npcs.forEach(n => { n.hp = n.hpMax; n.defeated = false; }); + this.render(false); + }); + + // Inline stat edits — save to state on input (no re-render) + root.querySelectorAll('.gnpc-stat-input').forEach(el => + el.addEventListener('input', () => { + const n = this._npcs.find(x => x.id === el.dataset.npcId); + if (n && el.dataset.field) n[el.dataset.field] = parseInt(el.value) || 0; + }) + ); + root.querySelectorAll('.gnpc-name-input').forEach(el => + el.addEventListener('input', () => { + const n = this._npcs.find(x => x.id === el.dataset.npcId); + if (n) n.name = el.value; + }) + ); + root.querySelectorAll('.gnpc-notes-input').forEach(el => + el.addEventListener('input', () => { + const n = this._npcs.find(x => x.id === el.dataset.npcId); + if (n) n.notes = el.value; + }) + ); + + // Create as World Actors + root.querySelector('.gnpc-create-btn')?.addEventListener('click', async () => { + if (!game.user?.isGM) return; + this._saveState(); + let created = 0; + for (const npc of this._npcs) { + try { + await Actor.create({ + name: npc.name, type: 'npc', + system: { + biography: `Type: ${npc.typeLabel}\n\nAbilities:\n${npc.abilities.join('\n')}${npc.notes ? '\n\nNotes:\n' + npc.notes : ''}`, + 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 + } + }); + created++; + } catch(err) { + 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.`); + }); + } +} + // ── ACTOR DIRECTORY BUTTONS ────────────────────────────────────────────────── Hooks.on('renderActorDirectory', (app, html) => { const root = html instanceof jQuery ? html[0] : html; @@ -5742,6 +6156,16 @@ Hooks.on('renderActorDirectory', (app, html) => { game.slaGMFinanceTool.render(true); }); actions.append(finBtn); + + const grpBtn = document.createElement('button'); + grpBtn.className = 'sla-group-npc-btn'; + grpBtn.innerHTML = ' Group NPCs'; + grpBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(100,200,80,0.15);border:1px solid #66cc44;color:#66cc44;border-radius:3px;cursor:pointer;'; + grpBtn.addEventListener('click', () => { + if (!game.slaGroupNPCTool) game.slaGroupNPCTool = new SLAGroupNPCTool(); + game.slaGroupNPCTool.render(true); + }); + actions.append(grpBtn); } actions.append(pcBtn, npcBtn);