chore: initial deploy — SLA Industries Foundry VTT

Co-authored-by: slaguru666 <timevans666@gmail.com>
This commit is contained in:
2026-03-31 17:00:38 +01:00
commit eea9fdb1e7
6 changed files with 343 additions and 0 deletions
+15
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
{
"SLANPCDirector.Title": "SLA NPC Director"
}
+32
View File
@@ -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"
}
+166
View File
@@ -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 `<li><strong>${foundry.utils.escapeHTML(entry.name)}</strong>: ${entry.stance}, ${entry.morale}, suppression ${entry.suppression}, intent ${entry.intent}${note}</li>`;
}).join("");
return `<section class="sla-npc-director-summary"><h2>SLA NPC Director</h2><ul>${lines}</ul></section>`;
}
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 = `<i class="fas fa-user-shield"></i> ${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);
+71
View File
@@ -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;
}
}
+56
View File
@@ -0,0 +1,56 @@
<form class="sla-npc-director-form">
<div class="sla-npc-director-toolbar">
<button type="button" data-action="refresh">Refresh Targets</button>
<button type="button" data-action="save">Save States</button>
<button type="button" data-action="chat">Whisper GM Summary</button>
</div>
{{#if entries.length}}
{{#each entries}}
<section class="sla-npc-director-row" data-doc-id="{{docId}}" data-doc-type="{{docType}}">
<div>
<label>Target</label>
<input type="text" value="{{name}}" disabled>
</div>
<div>
<label>Stance</label>
<select data-field="stance">
{{#each ../stanceOptions}}
<option value="{{this}}" {{#if (eq this ../stance)}}selected{{/if}}>{{this}}</option>
{{/each}}
</select>
</div>
<div>
<label>Morale</label>
<select data-field="morale">
{{#each ../moraleOptions}}
<option value="{{this}}" {{#if (eq this ../morale)}}selected{{/if}}>{{this}}</option>
{{/each}}
</select>
</div>
<div>
<label>Suppression</label>
<select data-field="suppression">
{{#each ../suppressionOptions}}
<option value="{{this}}" {{#if (eq this ../suppression)}}selected{{/if}}>{{this}}</option>
{{/each}}
</select>
</div>
<div>
<label>Intent</label>
<select data-field="intent">
{{#each ../intentOptions}}
<option value="{{this}}" {{#if (eq this ../intent)}}selected{{/if}}>{{this}}</option>
{{/each}}
</select>
</div>
<div>
<label>Note</label>
<input type="text" data-field="note" value="{{note}}">
</div>
</section>
{{/each}}
{{else}}
<p>No selected tokens or active combatants found.</p>
{{/if}}
</form>