From eea9fdb1e7e234473eae2cb1ce0771b3ab60cf20 Mon Sep 17 00:00:00 2001 From: "Tim Evans (slaguru666)" Date: Tue, 31 Mar 2026 17:00:38 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20deploy=20=E2=80=94=20SLA=20I?= =?UTF-8?q?ndustries=20Foundry=20VTT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: slaguru666 --- .gitignore | 15 ++++ lang/en.json | 3 + module.json | 32 +++++++ module/sla-npc-director.mjs | 166 ++++++++++++++++++++++++++++++++++++ styles/sla-npc-director.css | 71 +++++++++++++++ templates/npc-director.hbs | 56 ++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 .gitignore create mode 100644 lang/en.json create mode 100644 module.json create mode 100644 module/sla-npc-director.mjs create mode 100644 styles/sla-npc-director.css create mode 100644 templates/npc-director.hbs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b24d620 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +npm-debug.log* +.npm/ +dist/ +*.min.js +*.min.css +.DS_Store +.DS_Store? +._* +Thumbs.db +.vscode/ +.idea/ +*.swp +*.swo +worlds/ diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..6d97880 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,3 @@ +{ + "SLANPCDirector.Title": "SLA NPC Director" +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..ec60587 --- /dev/null +++ b/module.json @@ -0,0 +1,32 @@ +{ + "id": "sla-npc-director", + "title": "SLA NPC Director", + "description": "Tracks NPC morale, suppression, stance, and intent for selected tokens or current combatants.", + "version": "0.1.0", + "compatibility": { + "minimum": "13", + "verified": "13.350", + "maximum": "13" + }, + "authors": [ + { + "name": "Tim Evans" + } + ], + "esmodules": [ + "module/sla-npc-director.mjs" + ], + "styles": [ + "styles/sla-npc-director.css" + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "manifest": "https://raw.githubusercontent.com/slaguru666/sla-npc-director/main/module.json", + "download": "https://github.com/slaguru666/sla-npc-director/releases/latest/download/sla-npc-director.zip", + "url": "https://github.com/slaguru666/sla-npc-director" +} \ No newline at end of file diff --git a/module/sla-npc-director.mjs b/module/sla-npc-director.mjs new file mode 100644 index 0000000..90953f7 --- /dev/null +++ b/module/sla-npc-director.mjs @@ -0,0 +1,166 @@ +const MODULE_ID = "sla-npc-director"; +const FLAG_KEY = "directorState"; +const STANCE_OPTIONS = ["Aggressive", "Holding", "Defensive", "Broken"]; +const MORALE_OPTIONS = ["Steady", "Shaken", "Fragile", "Collapsed"]; +const SUPPRESSION_OPTIONS = ["0", "1", "2", "3"]; +const INTENT_OPTIONS = ["Rush", "Flank", "Hold", "Suppress", "Fallback", "Flee"]; + +function getHtmlRoot(html) { + return html instanceof HTMLElement ? html : html?.[0] ?? null; +} + +function getTargets() { + const selectedTokens = canvas?.tokens?.controlled?.map((token) => token.document) ?? []; + if (selectedTokens.length) return selectedTokens; + + const combatants = game.combat?.combatants?.map((combatant) => combatant.token ?? combatant.actor)?.filter(Boolean) ?? []; + return combatants; +} + +function readState(document) { + return foundry.utils.mergeObject( + { + stance: "Holding", + morale: "Steady", + suppression: "0", + intent: "Hold", + note: "" + }, + document?.getFlag?.(MODULE_ID, FLAG_KEY) ?? {} + ); +} + +async function writeState(document, state) { + if (!document?.setFlag) return; + await document.setFlag(MODULE_ID, FLAG_KEY, state); +} + +function buildSummary(entries) { + const lines = entries.map((entry) => { + const note = entry.note ? ` | ${foundry.utils.escapeHTML(entry.note)}` : ""; + return `
  • ${foundry.utils.escapeHTML(entry.name)}: ${entry.stance}, ${entry.morale}, suppression ${entry.suppression}, intent ${entry.intent}${note}
  • `; + }).join(""); + return `

    SLA NPC Director

      ${lines}
    `; +} + +class SlaNpcDirectorApp extends FormApplication { + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "sla-npc-director-app", + title: "SLA NPC Director", + template: "modules/sla-npc-director/templates/npc-director.hbs", + classes: ["sla-npc-director"], + width: 980, + height: "auto", + closeOnSubmit: false, + submitOnChange: false, + resizable: true + }); + } + + async getData() { + const entries = getTargets().map((document) => ({ + name: document.name ?? document.actor?.name ?? "Unknown", + docId: document.id, + docType: document.documentName, + ...readState(document) + })); + + return { + entries, + stanceOptions: STANCE_OPTIONS, + moraleOptions: MORALE_OPTIONS, + suppressionOptions: SUPPRESSION_OPTIONS, + intentOptions: INTENT_OPTIONS + }; + } + + activateListeners(html) { + super.activateListeners(html); + html.find("[data-action='refresh']").on("click", () => this.render(false)); + html.find("[data-action='save']").on("click", async () => { + await this._saveRows(); + ui.notifications.info("SLA NPC Director states saved."); + }); + html.find("[data-action='chat']").on("click", async () => { + const entries = await this._saveRows(); + await ChatMessage.create({ + user: game.user.id, + speaker: { alias: "SLA NPC Director" }, + whisper: ChatMessage.getWhisperRecipients("GM"), + content: buildSummary(entries) + }); + }); + } + + async _saveRows() { + const rows = Array.from(this.form?.querySelectorAll(".sla-npc-director-row") ?? []); + const summaries = []; + for (const row of rows) { + const docId = row.dataset.docId; + const document = canvas?.scene?.tokens?.get?.(docId) ?? game.combat?.combatants?.find((combatant) => combatant.token?.id === docId)?.token; + if (!document) continue; + const state = { + stance: row.querySelector("[data-field='stance']")?.value ?? "Holding", + morale: row.querySelector("[data-field='morale']")?.value ?? "Steady", + suppression: row.querySelector("[data-field='suppression']")?.value ?? "0", + intent: row.querySelector("[data-field='intent']")?.value ?? "Hold", + note: row.querySelector("[data-field='note']")?.value ?? "" + }; + await writeState(document, state); + summaries.push({ name: document.name ?? document.actor?.name ?? "Unknown", ...state }); + } + return summaries; + } + + async _updateObject() { + await this._saveRows(); + } + + static open() { + return new SlaNpcDirectorApp().render(true); + } +} + +function appendOpenButton(root, selector, className, label) { + const header = root?.querySelector(selector); + if (!header || header.querySelector(`.${className}`)) return false; + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.innerHTML = ` ${label}`; + button.addEventListener("click", () => SlaNpcDirectorApp.open()); + header.appendChild(button); + return true; +} + +function installCombatButton(app, html) { + const root = getHtmlRoot(html); + appendOpenButton(root, ".directory-header .header-actions, .directory-header .action-buttons, .combat-sidebar .encounter-controls", "sla-npc-director-open", "NPC Director"); +} + +function installActorDirectoryButton(app, html) { + const root = getHtmlRoot(html); + appendOpenButton(root, ".directory-header .header-actions, .directory-header .action-buttons", "sla-npc-director-actor-open", "NPC Director"); +} + +function installSettingsButton(app, html) { + const root = getHtmlRoot(html); + appendOpenButton(root, ".settings-sidebar #game-details, .settings-sidebar .directory-header .header-actions, .settings-sidebar .action-buttons", "sla-npc-director-settings-open", "NPC Director"); +} + +Hooks.once("init", () => { + Handlebars.registerHelper("eq", (left, right) => left === right); + game.settings.registerMenu(MODULE_ID, "npcDirectorMenu", { + name: "SLA NPC Director", + label: "Open NPC Director", + hint: "Track combat stance, morale, suppression, and intent for current targets.", + icon: "fas fa-user-shield", + type: SlaNpcDirectorApp, + restricted: true + }); +}); + +Hooks.on("renderCombatTracker", installCombatButton); +Hooks.on("renderActorDirectory", installActorDirectoryButton); +Hooks.on("renderSettings", installSettingsButton); diff --git a/styles/sla-npc-director.css b/styles/sla-npc-director.css new file mode 100644 index 0000000..54197ec --- /dev/null +++ b/styles/sla-npc-director.css @@ -0,0 +1,71 @@ +.sla-npc-director .window-content { + background: linear-gradient(180deg, #10161d 0%, #171d25 100%); + color: #f2eee5 !important; +} + +.sla-npc-director-form { + display: grid; + gap: 10px; +} + +.sla-npc-director-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.sla-npc-director-toolbar button { + background: linear-gradient(180deg, #d6b24e 0%, #b88a24 100%) !important; + border: 1px solid rgba(255, 232, 180, 0.55) !important; + border-radius: 999px; + color: #17140c !important; + padding: 7px 14px; + font-weight: 700; +} + +.sla-npc-director-row { + display: grid; + grid-template-columns: 1.4fr 0.9fr 0.9fr 0.9fr 1fr 1.2fr; + gap: 8px; + align-items: end; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(215, 181, 82, 0.24); + border-radius: 12px; + padding: 10px; +} + +.sla-npc-director-row label { + color: #f2eee5 !important; + display: block; + font-size: 0.8rem; + font-weight: 700; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.sla-npc-director-row input, +.sla-npc-director-row select { + width: 100%; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + color: #f2eee5 !important; + padding: 7px 9px; +} + +.sla-npc-director .window-title, +.sla-npc-director p, +.sla-npc-director span, +.sla-npc-director strong, +.sla-npc-director input, +.sla-npc-director select, +.sla-npc-director option { + color: #f2eee5 !important; +} + +@media (max-width: 920px) { + .sla-npc-director-row { + grid-template-columns: 1fr 1fr; + } +} diff --git a/templates/npc-director.hbs b/templates/npc-director.hbs new file mode 100644 index 0000000..d9ea0e8 --- /dev/null +++ b/templates/npc-director.hbs @@ -0,0 +1,56 @@ +
    +
    + + + +
    + + {{#if entries.length}} + {{#each entries}} +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + {{/each}} + {{else}} +

    No selected tokens or active combatants found.

    + {{/if}} +