chore: initial deploy — SLA Industries Foundry VTT

This commit is contained in:
2026-04-08 09:29:08 +01:00
commit 1804011b73
11 changed files with 1577 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.DS_Store
._*
*.swp
worlds/
+53
View File
@@ -0,0 +1,53 @@
{
"NPC_QUICK_CARDS": {
"title": "NPC Card Deck",
"toggleButton": "NPC Deck",
"noCards": "No NPC cards yet.",
"noCardsHint": "Click any NPC token on the canvas to auto-create a card.",
"addTag": "+ Add Tag",
"addTagTitle": "Add Personality Tag",
"addTagText": "Tag text",
"addTagCat": "Category",
"addSecret": "+ Add Secret",
"addSecretTitle": "Add Secret",
"addSecretText": "Secret text",
"openSheet": "Open Actor Sheet",
"voiceSection": "Voice & Manner",
"tagsSection": "Personality Tags",
"attitudeSection":"Attitude to PCs",
"secretsSection": "Secrets",
"accentPlaceholder": "Accent / region...",
"mannerPlaceholder": "Manner note — posture, gestures, eye contact...",
"quirkCustomPlaceholder": "Describe the quirk...",
"hostile": "Hostile",
"trusted": "Trusted",
"markRevealed": "Mark as revealed",
"markHidden": "Mark as hidden",
"speechQuirkNone": "— Speech quirk —",
"quirk_never-answers-directly": "Never answers directly",
"quirk_speaks-in-questions": "Speaks in questions",
"quirk_excessive-use-of-titles": "Excessive use of titles",
"quirk_repeats-last-word": "Repeats last word",
"quirk_long-pauses": "Long pauses",
"quirk_talks-over-people": "Talks over people",
"quirk_whisperer": "Whisperer",
"quirk_overly-formal": "Overly formal",
"quirk_custom": "Custom...",
"cat_motivation": "Motivation (amber)",
"cat_relationship": "Relationship (blue)",
"cat_knowledge": "Knowledge (green)",
"cat_danger": "Danger (red)",
"cat_other": "Other (grey)"
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"id": "npc-quick-cards",
"title": "NPC Quick Card Deck",
"description": "A GM-only floating panel of NPC reference cards. Click a token to bring that NPC's card to the front. Stores personality notes, voice descriptions, per-PC attitude tracking, and secrets. Fully offline, system-agnostic.",
"version": "1.0.0",
"compatibility": {
"minimum": "12",
"verified": "13"
},
"authors": [
{
"name": "Tim Evans"
}
],
"esmodules": [
"scripts/main.js"
],
"styles": [
"styles/npc-quick-cards.css"
],
"languages": [
{
"lang": "en",
"name": "English",
"path": "lang/en.json"
}
],
"flags": {},
"manifest": "https://raw.githubusercontent.com/slaguru666/npc-quick-cards/main/module.json",
"download": "https://github.com/slaguru666/npc-quick-cards/releases/latest/download/npc-quick-cards.zip"
}
+473
View File
@@ -0,0 +1,473 @@
/**
* card-deck.js
* NPCCardDeck — the main ApplicationV2 floating panel.
*
* Fixes applied over the spec's known limitations:
* • Attitude tracker uses a pre-processed attitudesArray, so no {{lookup}} helper needed
* • Tooltip position uses canvas.clientCoordinatesFromCanvas() with a PIXI transform fallback
* • "Add Tag" and "Add Secret" are fully-implemented Dialog prompts
* • Tag removal is wired up via × buttons on each tag chip
* • Actor directory button is de-duplicated via presence check
* • Cards are filtered to: (a) actors with saved card data, OR (b) actors present in current scene
* • Select fields (speechQuirk) fire on "change" so the custom field appears without a blur
* • minimizable uses the correct Foundry spelling
*/
import { NPCCardData, MODULE_ID, FLAG_KEY, SPEECH_QUIRKS, TAG_CATEGORIES } from "./npc-card.js";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export class NPCCardDeck extends HandlebarsApplicationMixin(ApplicationV2) {
// ── Application configuration ────────────────────────────────────────────────
static DEFAULT_OPTIONS = {
id: "npc-card-deck",
classes: ["npc-quick-cards", "deck-window"],
position: { width: 340, height: 600, top: 80, left: 20 },
window: {
title: "NPC Card Deck",
resizable: true,
minimizable: true,
},
};
static PARTS = {
deck: {
template: "modules/npc-quick-cards/templates/card-deck.hbs",
scrollable: [".npc-deck-container"],
},
};
// ── Private state ────────────────────────────────────────────────────────────
/** @type {string|null} Actor ID of the currently expanded card */
#activeCardId = null;
// ── Data preparation ─────────────────────────────────────────────────────────
async _prepareContext(options) {
// Collect all PC actors for the attitude tracker rows.
// Uses hasPlayerOwner rather than actor.type so this works in systems like
// SLA Mothership where every actor uses the same "character" type.
const playerActors = game.actors
.filter(a => a.hasPlayerOwner)
.sort((a, b) => a.name.localeCompare(b.name))
.map(a => ({ id: a.id, name: a.name }));
// Determine which NPC actors are in the current scene (linked tokens only)
const sceneActorIds = new Set(
(canvas.scene?.tokens?.contents ?? [])
.map(t => t.actorId)
.filter(Boolean)
);
// Include an actor if it has saved card data OR it has a token in the current scene.
// !hasPlayerOwner means "no player owns this actor" — i.e. it's a GM-controlled NPC.
// This is system-agnostic and handles SLA Mothership (all actors share one type).
const cards = game.actors
.filter(a => game.user.isGM || a.isOwner)
.filter(a => !a.hasPlayerOwner)
.filter(a => NPCCardData.hasCardData(a) || sceneActorIds.has(a.id))
.sort((a, b) => a.name.localeCompare(b.name))
.map(a => this._buildCardData(a, playerActors));
return {
cards,
playerActors,
activeCardId: this.#activeCardId,
};
}
/**
* Flatten one actor's flags + metadata into a template-ready object.
* Pre-processes attitudes into an array so the template needs no {{lookup}}.
* @param {Actor} actor
* @param {object[]} playerActors [{ id, name }]
* @returns {object}
*/
_buildCardData(actor, playerActors) {
const flags = NPCCardData.getFlags(actor);
return {
actorId: actor.id,
actorName: actor.name,
actorImg: actor.img,
// Voice & Manner
accent: flags.accent ?? "",
speechQuirk: flags.speechQuirk ?? "",
speechQuirkCustom: flags.speechQuirkCustom ?? "",
mannerNote: flags.mannerNote ?? "",
// Tags and secrets
tags: flags.tags ?? [],
secrets: flags.secrets ?? [],
// Attitude: pre-built array for template — avoids {{lookup}} helper
attitudesArray: NPCCardData.buildAttitudesArray(flags.attitudes, playerActors),
// Links
linkedJournalId: flags.linkedJournalId ?? null,
// Expand state
isActive: actor.id === this.#activeCardId,
};
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Bring a card to the front and open the deck if it is not already open.
* Auto-creates empty card data if the actor has none yet.
* @param {string} actorId
*/
async bringCardToFront(actorId) {
const actor = game.actors.get(actorId);
if (!actor) return;
// Auto-create empty card data so the actor appears in the deck
if (!NPCCardData.hasCardData(actor)) {
await NPCCardData.setFlags(actor, NPCCardData.empty());
}
this.#activeCardId = actorId;
if (!this.rendered) {
await this.render(true);
// Let the DOM settle before scrolling
setTimeout(() => this._scrollAndFlash(actorId), 120);
} else {
await this.render();
requestAnimationFrame(() => this._scrollAndFlash(actorId));
}
}
/**
* Scroll the named card into view and briefly flash it gold.
* @param {string} actorId
*/
_scrollAndFlash(actorId) {
const el = this.element?.querySelector(`.npc-card[data-actor-id="${actorId}"]`);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
el.classList.add("flash-highlight");
setTimeout(() => el.classList.remove("flash-highlight"), 800);
}
/**
* Show a brief accent / quirk tooltip near a token on the canvas.
* Uses canvas.clientCoordinatesFromCanvas() in v13, with a PIXI-transform fallback.
* @param {string} actorId
* @param {Token} token
*/
showTooltip(actorId, token) {
const actor = game.actors.get(actorId);
if (!actor) return;
const flags = NPCCardData.getFlags(actor);
const resolvedQuirk = NPCCardData.resolvedQuirk(flags);
const topTags = (flags.tags ?? []).slice(0, 3).map(t => t.text).join(" · ");
// ── Resolve screen coordinates ───────────────────────────────────────────
let screenX = 140, screenY = 80;
try {
const center = token.center ?? { x: token.x, y: token.y };
if (typeof canvas.clientCoordinatesFromCanvas === "function") {
// v13 API
const pos = canvas.clientCoordinatesFromCanvas(center);
screenX = pos.x + 64;
screenY = pos.y;
} else {
// Fallback: read PIXI stage world transform directly
const wt = canvas.stage?.worldTransform;
if (wt) {
screenX = center.x * wt.a + wt.tx + 64;
screenY = center.y * wt.d + wt.ty;
}
}
} catch (_) {
// Position falls back to safe defaults — tooltip still appears
}
// ── Build tooltip element ────────────────────────────────────────────────
this.hideTooltip(); // remove any lingering tooltip
const tip = document.createElement("div");
tip.id = "npc-card-tooltip";
tip.className = "npc-card-tooltip";
tip.style.left = `${Math.round(screenX)}px`;
tip.style.top = `${Math.round(screenY)}px`;
const rows = [`<strong>${actor.name}</strong>`];
if (flags.accent) rows.push(`<em>${flags.accent}</em>`);
if (resolvedQuirk) rows.push(`Quirk: ${resolvedQuirk}`);
if (topTags) rows.push(topTags);
tip.innerHTML = rows.join("<br>");
const mount = document.getElementById("interface") ?? document.body;
mount.appendChild(tip);
}
/** Remove the hover tooltip if it is present. */
hideTooltip() {
document.getElementById("npc-card-tooltip")?.remove();
}
/** Open or close the deck panel. */
toggle() {
this.rendered ? this.close() : this.render(true);
}
// ── Data mutation helpers ────────────────────────────────────────────────────
/**
* Persist card data for one actor and trigger a re-render.
* @param {string} actorId
* @param {object} data
*/
async saveCardData(actorId, data) {
const actor = game.actors.get(actorId);
if (!actor || !game.user.isGM) return;
await NPCCardData.setFlags(actor, data);
this.render();
}
/**
* Flip the revealed flag on one secret entry.
* @param {string} actorId
* @param {string} secretId
*/
async toggleSecret(actorId, secretId) {
const actor = game.actors.get(actorId);
if (!actor) return;
const flags = NPCCardData.getFlags(actor);
const secret = (flags.secrets ?? []).find(s => s.id === secretId);
if (secret) secret.revealed = !secret.revealed;
await this.saveCardData(actorId, flags);
}
/**
* Open a Dialog to collect tag text + category and append it to the actor's flags.
* @param {string} actorId
*/
async addTag(actorId) {
const categoriesHtml = TAG_CATEGORIES
.map(c => `<option value="${c.value}">${c.label}</option>`)
.join("");
return new Promise(resolve => {
new Dialog({
title: "Add Personality Tag",
content: `
<form class="npc-dialog-form">
<div class="form-group">
<label>Tag text</label>
<input type="text" name="tagText" placeholder="e.g. Paranoid, Loyal to the Duke…" autofocus />
</div>
<div class="form-group">
<label>Category</label>
<select name="tagCategory">${categoriesHtml}</select>
</div>
</form>`,
buttons: {
add: {
icon: "<i class='fas fa-plus'></i>",
label: "Add",
callback: async html => {
const text = html.find("[name='tagText']").val().trim();
const category = html.find("[name='tagCategory']").val();
if (!text) return resolve();
const actor = game.actors.get(actorId);
if (!actor) return resolve();
const flags = NPCCardData.getFlags(actor);
flags.tags = flags.tags ?? [];
flags.tags.push({ text, category });
await this.saveCardData(actorId, flags);
resolve();
},
},
cancel: {
icon: "<i class='fas fa-times'></i>",
label: "Cancel",
callback: () => resolve(),
},
},
default: "add",
}).render(true);
});
}
/**
* Remove the tag at the given index from an actor's flags.
* @param {string} actorId
* @param {number} tagIndex
*/
async removeTag(actorId, tagIndex) {
const actor = game.actors.get(actorId);
if (!actor) return;
const flags = NPCCardData.getFlags(actor);
(flags.tags ?? []).splice(tagIndex, 1);
await this.saveCardData(actorId, flags);
}
/**
* Open a Dialog to collect secret text and append it to the actor's flags.
* @param {string} actorId
*/
async addSecret(actorId) {
return new Promise(resolve => {
new Dialog({
title: "Add Secret",
content: `
<form class="npc-dialog-form">
<div class="form-group">
<label>Secret text</label>
<textarea
name="secretText"
rows="3"
placeholder="What does this NPC know, hide, or dread?"
autofocus></textarea>
</div>
</form>`,
buttons: {
add: {
icon: "<i class='fas fa-plus'></i>",
label: "Add",
callback: async html => {
const text = html.find("[name='secretText']").val().trim();
if (!text) return resolve();
const actor = game.actors.get(actorId);
if (!actor) return resolve();
const flags = NPCCardData.getFlags(actor);
flags.secrets = flags.secrets ?? [];
flags.secrets.push({
id: NPCCardData.generateId(),
text,
revealed: false,
});
await this.saveCardData(actorId, flags);
resolve();
},
},
cancel: {
icon: "<i class='fas fa-times'></i>",
label: "Cancel",
callback: () => resolve(),
},
},
default: "add",
}).render(true);
});
}
// ── Render lifecycle ─────────────────────────────────────────────────────────
/**
* Wire all interactive elements after every render.
* ApplicationV2 replaces the full part HTML on each render(), so listeners
* must always be re-attached here.
* @param {object} context
* @param {object} options
*/
_onRender(context, options) {
const root = this.element;
if (!root) return;
// ── Expand / collapse card on header click ───────────────────────────────
root.querySelectorAll(".card-header").forEach(header => {
header.addEventListener("click", e => {
// Don't collapse when the user clicks an action icon inside the header
if (e.target.closest(".card-header-actions")) return;
const actorId = header.closest(".npc-card").dataset.actorId;
this.#activeCardId = (this.#activeCardId === actorId) ? null : actorId;
this.render();
});
});
// ── Attitude sliders ─────────────────────────────────────────────────────
root.querySelectorAll(".attitude-slider").forEach(slider => {
slider.addEventListener("change", async e => {
const actorId = e.target.closest(".npc-card").dataset.actorId;
const pcId = e.target.dataset.pcId;
const value = parseInt(e.target.value, 10);
const actor = game.actors.get(actorId);
if (!actor) return;
const flags = NPCCardData.getFlags(actor);
flags.attitudes = flags.attitudes ?? {};
flags.attitudes[pcId] = value;
await this.saveCardData(actorId, flags);
});
});
// ── Secret toggle buttons ────────────────────────────────────────────────
root.querySelectorAll(".secret-toggle").forEach(btn => {
btn.addEventListener("click", e => {
const actorId = e.currentTarget.closest(".npc-card").dataset.actorId;
const secretId = e.currentTarget.dataset.secretId;
this.toggleSecret(actorId, secretId);
});
});
// ── Open full actor sheet ────────────────────────────────────────────────
root.querySelectorAll(".open-actor-sheet").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation(); // prevent card collapse
const actorId = e.currentTarget.closest(".npc-card").dataset.actorId;
game.actors.get(actorId)?.sheet.render(true);
});
});
// ── Add Tag button ───────────────────────────────────────────────────────
root.querySelectorAll(".add-tag-btn").forEach(btn => {
btn.addEventListener("click", e => {
this.addTag(e.currentTarget.dataset.actorId);
});
});
// ── Tag remove (×) button ────────────────────────────────────────────────
root.querySelectorAll(".tag-remove").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
const actorId = e.currentTarget.closest(".npc-card").dataset.actorId;
const tagIndex = parseInt(e.currentTarget.dataset.tagIndex, 10);
this.removeTag(actorId, tagIndex);
});
});
// ── Add Secret button ────────────────────────────────────────────────────
root.querySelectorAll(".add-secret-btn").forEach(btn => {
btn.addEventListener("click", e => {
this.addSecret(e.currentTarget.dataset.actorId);
});
});
// ── Text / textarea field saves on blur ──────────────────────────────────
root.querySelectorAll("input.card-field[data-field], textarea.card-field[data-field]")
.forEach(input => {
input.addEventListener("blur", async e => {
const card = e.target.closest(".npc-card");
if (!card) return;
const actorId = card.dataset.actorId;
const field = e.target.dataset.field;
const actor = game.actors.get(actorId);
if (!actor) return;
const flags = NPCCardData.getFlags(actor);
flags[field] = e.target.value;
await this.saveCardData(actorId, flags);
});
});
// ── Select field saves on change ─────────────────────────────────────────
// This also triggers a re-render so the custom speech-quirk input can appear.
root.querySelectorAll("select.card-field[data-field]").forEach(select => {
select.addEventListener("change", async e => {
const card = e.target.closest(".npc-card");
if (!card) return;
const actorId = card.dataset.actorId;
const field = e.target.dataset.field;
const actor = game.actors.get(actorId);
if (!actor) return;
const flags = NPCCardData.getFlags(actor);
flags[field] = e.target.value;
await this.saveCardData(actorId, flags);
// Re-render triggers the {{#if (eq speechQuirk "custom")}} block
this.render();
});
});
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* main.js
* Module entry point for NPC Quick Card Deck.
*
* Load order:
* 1. Hooks.once("init") — register Handlebars helpers before any template renders
* 2. Hooks.once("ready") — create the singleton deck, register token hooks,
* and inject the toolbar button into the Actor Directory
*/
import { NPCCardDeck } from "./card-deck.js";
import { registerTokenHooks } from "./token-hooks.js";
// ── init ───────────────────────────────────────────────────────────────────────
Hooks.once("init", () => {
console.log("NPC Quick Cards | Initialising");
// Register Handlebars helpers.
// `eq` is built-in from Foundry v10 onward but we register defensively
// in case a stripped-down environment doesn't have it.
if (!Handlebars.helpers["eq"]) {
Handlebars.registerHelper("eq", (a, b) => a === b);
}
// Useful for attitude slider label comparisons in templates
Handlebars.registerHelper("nqc-gt", (a, b) => Number(a) > Number(b));
Handlebars.registerHelper("nqc-lt", (a, b) => Number(a) < Number(b));
// Pre-load templates so they are cached before the deck first opens.
// v13 moved loadTemplates under foundry.applications.handlebars; fall back
// to the global for v12 compatibility.
const _loadTemplates =
foundry.applications?.handlebars?.loadTemplates?.bind(foundry.applications.handlebars)
?? globalThis.loadTemplates;
_loadTemplates([
"modules/npc-quick-cards/templates/card-deck.hbs",
"modules/npc-quick-cards/templates/npc-card.hbs",
"modules/npc-quick-cards/templates/card-tooltip.hbs",
]);
});
// ── ready ──────────────────────────────────────────────────────────────────────
Hooks.once("ready", () => {
// Only the GM needs the deck; bail out for players
if (!game.user.isGM) return;
// Create singleton and expose on game so macros can call game.npcCardDeck.toggle()
game.npcCardDeck = new NPCCardDeck();
// Register canvas token interaction hooks
registerTokenHooks(game.npcCardDeck);
console.log("NPC Quick Cards | Ready");
});
// ── Actor Directory toolbar button ─────────────────────────────────────────────
// Injected here (outside "ready") so it re-fires every time the directory re-renders.
//
// Foundry v13 passes a plain HTMLElement as the second argument; v12 passed a
// jQuery object. We normalise to a plain element so the same code works on both.
// We also try every known selector for the header actions area across v12 / v13
// because the DOM structure changed between versions.
Hooks.on("renderActorDirectory", (_app, html) => {
if (!game.user?.isGM) return;
if (!game.npcCardDeck) return;
// Resolve to a plain HTMLElement regardless of Foundry version
const root = (html instanceof HTMLElement) ? html : (html[0] ?? html);
if (!(root instanceof HTMLElement)) return;
if (root.querySelector(".npc-deck-toggle")) return; // prevent duplicates
const btn = document.createElement("button");
btn.type = "button";
btn.className = "npc-deck-toggle";
btn.title = "NPC Quick Card Deck";
btn.innerHTML = '<i class="fas fa-address-card"></i> NPC Deck';
btn.addEventListener("click", () => game.npcCardDeck.toggle());
// Try every known header-action selector across Foundry v12 and v13.
// Log which one we find so the console makes it easy to debug.
const SELECTORS = [
".directory-header .header-actions", // v12 standard
".directory-header .action-buttons", // v13 variant
"header .header-actions", // v13 <header> element
"header .action-buttons", // v13 variant
".header-actions", // any version, loose
".action-buttons", // any version, loose
".directory-header", // fallback: whole header
"header", // fallback: <header> element
];
let target = null;
for (const sel of SELECTORS) {
target = root.querySelector(sel);
if (target) {
console.log(`NPC Quick Cards | Injecting button into "${sel}"`);
break;
}
}
if (target) {
target.prepend(btn);
} else {
// Absolute last resort — prepend directly to the directory root
console.warn("NPC Quick Cards | Could not find a header target; prepending to root.");
root.prepend(btn);
}
});
+139
View File
@@ -0,0 +1,139 @@
/**
* npc-card.js
* Data model constants and static helper class for NPC Quick Cards.
* No instantiation needed — use NPCCardData static methods throughout.
*/
export const MODULE_ID = "npc-quick-cards";
export const FLAG_KEY = "cardData";
// ── Speech quirk options ───────────────────────────────────────────────────────
export const SPEECH_QUIRKS = [
{ value: "never-answers-directly", label: "Never answers directly" },
{ value: "speaks-in-questions", label: "Speaks in questions" },
{ value: "excessive-use-of-titles", label: "Excessive use of titles" },
{ value: "repeats-last-word", label: "Repeats last word" },
{ value: "long-pauses", label: "Long pauses" },
{ value: "talks-over-people", label: "Talks over people" },
{ value: "whisperer", label: "Whisperer" },
{ value: "overly-formal", label: "Overly formal" },
{ value: "custom", label: "Custom..." },
];
// ── Personality tag categories ─────────────────────────────────────────────────
export const TAG_CATEGORIES = [
{ value: "motivation", label: "Motivation (amber)" },
{ value: "relationship", label: "Relationship (blue)" },
{ value: "knowledge", label: "Knowledge (green)" },
{ value: "danger", label: "Danger (red)" },
{ value: "other", label: "Other (grey)" },
];
// ── Static data helpers ────────────────────────────────────────────────────────
export class NPCCardData {
/**
* Read card data flags from an actor with safe defaults.
* Always returns a deep clone so callers can mutate freely.
* @param {Actor} actor
* @returns {object}
*/
static getFlags(actor) {
return foundry.utils.deepClone(
actor.getFlag(MODULE_ID, FLAG_KEY) ?? {}
);
}
/**
* Persist card data flags to an actor.
* @param {Actor} actor
* @param {object} data
* @returns {Promise}
*/
static async setFlags(actor, data) {
return actor.setFlag(MODULE_ID, FLAG_KEY, data);
}
/**
* Return true if this actor has any card data saved.
* @param {Actor} actor
* @returns {boolean}
*/
static hasCardData(actor) {
const flags = actor.getFlag(MODULE_ID, FLAG_KEY);
return flags !== undefined && flags !== null;
}
/**
* Create a fresh, empty card data object.
* @returns {object}
*/
static empty() {
return {
accent: "",
speechQuirk: "",
speechQuirkCustom: "",
mannerNote: "",
tags: [],
attitudes: {},
secrets: [],
linkedJournalId: null,
lastSceneId: null,
};
}
/**
* Generate a short random ID suitable for secret entries.
* @returns {string}
*/
static generateId() {
return foundry.utils.randomID(10);
}
/**
* Look up the display label for a speech quirk value.
* @param {string} value
* @returns {string}
*/
static quirkLabel(value) {
if (!value) return "";
return SPEECH_QUIRKS.find(q => q.value === value)?.label ?? value;
}
/**
* Resolve the effective speech quirk display string for a card's flags.
* Falls back to the custom text when quirk === "custom".
* @param {object} flags
* @returns {string}
*/
static resolvedQuirk(flags) {
if (!flags.speechQuirk) return "";
if (flags.speechQuirk === "custom") return flags.speechQuirkCustom || "Custom";
return NPCCardData.quirkLabel(flags.speechQuirk);
}
/**
* Build an attitudes array (sorted by PC name) suitable for template iteration.
* Avoids the Handlebars {{lookup}} helper.
* @param {object} attitudesObj - raw attitudes map { actorId: number }
* @param {object[]} playerActors - [{ id, name }]
* @returns {{ pcId: string, pcName: string, value: number }[]}
*/
static buildAttitudesArray(attitudesObj, playerActors) {
return playerActors.map(pc => ({
pcId: pc.id,
pcName: pc.name,
value: attitudesObj?.[pc.id] ?? 0,
}));
}
/**
* Return a CSS class string describing an attitude value.
* @param {number} value -3 to +3
* @returns {string}
*/
static attitudeClass(value) {
if (value >= 2) return "attitude-trusted";
if (value <= -2) return "attitude-hostile";
return "attitude-neutral";
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* token-hooks.js
* Registers Foundry canvas token hooks for the NPC Quick Card Deck.
*
* controlToken → when the GM selects an NPC token, bring that card to front.
* hoverToken → after 1.5 s of hovering over an NPC token, show the accent tooltip.
*/
export function registerTokenHooks(deck) {
// ── controlToken ────────────────────────────────────────────────────────────
// Fires whenever any token is selected or deselected.
Hooks.on("controlToken", (token, controlled) => {
if (!controlled) return; // only react to selection, not deselection
if (!game.user.isGM) return; // GM only
if (!token.actor) return; // token may not be linked to an actor
// Skip player-owned actors (PCs). Using hasPlayerOwner instead of checking
// actor.type means this works in SLA Mothership and other single-type systems.
if (token.actor.hasPlayerOwner) return;
deck.bringCardToFront(token.actor.id);
});
// ── hoverToken ──────────────────────────────────────────────────────────────
// Fires on mouse-enter and mouse-leave over a token.
let hoverTimer = null;
Hooks.on("hoverToken", (token, hovered) => {
if (!game.user.isGM) return;
if (!token.actor || token.actor.hasPlayerOwner) return;
if (hovered) {
// Delay before showing tooltip so it doesn't flash on quick mouse passes
hoverTimer = setTimeout(() => {
deck.showTooltip(token.actor.id, token);
}, 1500);
} else {
clearTimeout(hoverTimer);
hoverTimer = null;
deck.hideTooltip();
}
});
}
+443
View File
@@ -0,0 +1,443 @@
/* ============================================================
NPC Quick Cards — npc-quick-cards.css
All styles scoped to .npc-quick-cards or an ID unique to
this module, so nothing bleeds into other UI.
============================================================ */
/* ── Deck window shell ────────────────────────────────────────────────────── */
.npc-quick-cards.deck-window {
/* Let the window chrome show through; internal scroll handled below */
}
.npc-quick-cards .npc-deck-container {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
overflow-y: auto;
/* Max-height matches DEFAULT_OPTIONS height minus chrome (~38 px) */
max-height: calc(100% - 8px);
box-sizing: border-box;
}
/* ── Individual card ──────────────────────────────────────────────────────── */
.npc-card {
border: 1px solid #444;
border-radius: 5px;
background: #1a1a1a;
transition: border-color 0.18s ease;
overflow: hidden;
}
.npc-card.active {
border-color: #c0a060;
box-shadow: 0 0 6px rgba(192, 160, 96, 0.25);
}
/* Flash animation: fires when bringCardToFront() finds the element */
.npc-card.flash-highlight {
animation: nqc-card-flash 0.8s ease-out forwards;
}
@keyframes nqc-card-flash {
0% { background: #3a2a10; border-color: #e8c070; }
100% { background: #1a1a1a; border-color: #c0a060; }
}
/* ── Card header ──────────────────────────────────────────────────────────── */
.card-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
user-select: none;
border-radius: 4px 4px 0 0;
transition: background 0.12s;
}
.card-header:hover {
background: rgba(255, 255, 255, 0.04);
}
.card-portrait {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #555;
flex-shrink: 0;
}
.card-name {
flex: 1;
font-weight: 700;
font-size: 0.88em;
color: #e0c080;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-header-actions {
display: flex;
gap: 6px;
align-items: center;
color: #666;
flex-shrink: 0;
}
.card-header-actions a {
color: #666;
text-decoration: none;
transition: color 0.12s;
}
.card-header-actions a:hover,
.card-toggle-icon:hover {
color: #c0a060;
}
/* ── Card body ────────────────────────────────────────────────────────────── */
.card-body {
padding: 8px 10px 10px;
border-top: 1px solid #2e2e2e;
}
.card-section {
margin-bottom: 14px;
}
.card-section:last-child {
margin-bottom: 0;
}
.card-section h4 {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.72em;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #777;
margin: 0 0 6px 0;
padding-bottom: 4px;
border-bottom: 1px solid #2a2a2a;
}
.card-section h4 .fas {
color: #555;
font-size: 0.9em;
}
/* ── Form fields inside a card ────────────────────────────────────────────── */
.card-field {
width: 100%;
background: #111;
border: 1px solid #3a3a3a;
color: #d8d8d8;
padding: 4px 7px;
border-radius: 3px;
font-size: 0.83em;
margin-bottom: 5px;
box-sizing: border-box;
font-family: inherit;
transition: border-color 0.12s;
}
.card-field:focus {
border-color: #c0a060;
outline: none;
background: #141414;
}
textarea.card-field {
resize: vertical;
min-height: 56px;
}
select.card-field {
cursor: pointer;
appearance: auto; /* let the browser render the native dropdown arrow */
}
/* Read-only display fields used in npc-card.hbs standalone template */
.card-display-field {
font-size: 0.84em;
color: #c8c8c8;
margin: 0 0 4px 0;
line-height: 1.4;
}
.quirk-display { font-style: italic; color: #a8a8a8; }
.manner-display { color: #b8b8b8; }
/* ── Personality tags ─────────────────────────────────────────────────────── */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.personality-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 9px;
border-radius: 12px;
font-size: 0.74em;
font-weight: 700;
line-height: 1.6;
}
/* Category colour variants */
.tag-motivation { background: #3d2a00; color: #ffc944; border: 1px solid #7a5500; }
.tag-relationship { background: #001a3d; color: #5ab0ff; border: 1px solid #004080; }
.tag-knowledge { background: #002010; color: #55cc88; border: 1px solid #005530; }
.tag-danger { background: #2d0000; color: #ff6655; border: 1px solid #660000; }
.tag-other { background: #1e1e1e; color: #aaaaaa; border: 1px solid #444; }
/* × remove button inside a tag chip */
.tag-remove {
background: none;
border: none;
cursor: pointer;
color: inherit;
opacity: 0.5;
font-size: 0.95em;
padding: 0;
line-height: 1;
transition: opacity 0.12s;
}
.tag-remove:hover {
opacity: 1;
}
/* "Add Tag" / "Add Secret" dashed-border ghost buttons */
.add-tag-btn,
.add-secret-btn {
background: none;
border: 1px dashed #484848;
color: #777;
font-size: 0.74em;
padding: 3px 10px;
border-radius: 3px;
cursor: pointer;
margin-top: 2px;
transition: border-color 0.12s, color 0.12s;
font-family: inherit;
}
.add-tag-btn:hover,
.add-secret-btn:hover {
border-color: #c0a060;
color: #c0a060;
}
/* ── Attitude tracker ─────────────────────────────────────────────────────── */
.attitude-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 5px;
font-size: 0.8em;
}
.attitude-pc-name {
min-width: 76px;
max-width: 76px;
color: #ccc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.88em;
}
.attitude-slider {
flex: 1;
accent-color: #c0a060;
cursor: pointer;
}
.attitude-label {
font-size: 0.7em;
min-width: 38px;
color: #555;
}
.attitude-label.hostile { color: #cc5544; text-align: right; }
.attitude-label.trusted { color: #55cc88; }
/* ── Secrets ──────────────────────────────────────────────────────────────── */
.secrets-list {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 6px;
}
.secret-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 7px;
border-radius: 3px;
background: #0f0f0f;
border: 1px solid #242424;
transition: opacity 0.2s;
}
.secret-item.revealed {
opacity: 0.45;
}
.secret-item.revealed .secret-text {
text-decoration: line-through;
color: #888;
}
.secret-text {
flex: 1;
font-size: 0.82em;
color: #d0d0d0;
line-height: 1.35;
}
.secret-toggle {
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 0 4px;
font-size: 0.9em;
transition: color 0.12s;
flex-shrink: 0;
}
.secret-toggle:hover {
color: #c0a060;
}
/* ── Hover tooltip ────────────────────────────────────────────────────────── */
#npc-card-tooltip,
.npc-card-tooltip {
position: fixed;
z-index: 9999;
background: rgba(8, 8, 8, 0.93);
border: 1px solid #c0a060;
color: #e0d0b0;
padding: 7px 11px;
border-radius: 4px;
font-size: 0.8em;
line-height: 1.5;
max-width: 260px;
pointer-events: none;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.6);
}
#npc-card-tooltip strong,
.npc-card-tooltip strong {
display: block;
color: #e8c880;
font-size: 1.05em;
margin-bottom: 2px;
}
#npc-card-tooltip em,
.npc-card-tooltip em {
display: block;
font-style: italic;
color: #c0b090;
}
#npc-card-tooltip span,
.npc-card-tooltip span {
display: block;
color: #a0a0a0;
font-size: 0.92em;
}
/* ── Empty state ──────────────────────────────────────────────────────────── */
.deck-empty {
padding: 30px 20px;
text-align: center;
color: #555;
font-size: 0.85em;
line-height: 1.6;
}
.deck-empty-icon {
font-size: 2.5em;
opacity: 0.18;
display: block;
margin-bottom: 12px;
}
.deck-empty p {
margin: 0 0 6px 0;
}
.deck-empty-hint {
font-size: 0.9em;
color: #444;
}
/* ── Actor Directory toolbar button ───────────────────────────────────────── */
.npc-deck-toggle {
font-size: 0.75em;
padding: 2px 9px;
cursor: pointer;
white-space: nowrap;
}
/* ── Add Tag / Add Secret dialog form ─────────────────────────────────────── */
.npc-dialog-form .form-group {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.npc-dialog-form label {
font-size: 0.82em;
color: #aaa;
font-weight: 600;
}
.npc-dialog-form input[type="text"],
.npc-dialog-form textarea,
.npc-dialog-form select {
background: #111;
border: 1px solid #444;
color: #ddd;
border-radius: 3px;
padding: 5px 7px;
font-family: inherit;
font-size: 0.9em;
}
.npc-dialog-form textarea {
resize: vertical;
min-height: 72px;
}
.npc-dialog-form input:focus,
.npc-dialog-form textarea:focus,
.npc-dialog-form select:focus {
border-color: #c0a060;
outline: none;
}
+162
View File
@@ -0,0 +1,162 @@
{{!--
card-deck.hbs
Root template for the NPCCardDeck ApplicationV2 panel.
Context shape (from NPCCardDeck._prepareContext):
cards[] — array of card data objects (see _buildCardData)
playerActors[] — [{ id, name }] for the attitude tracker rows
activeCardId — actor ID of the currently expanded card (or null)
Each card object has:
actorId, actorName, actorImg
accent, speechQuirk, speechQuirkCustom, mannerNote
tags[] — [{ text, category }]
attitudesArray[]— [{ pcId, pcName, value }] (pre-built, no {{lookup}} needed)
secrets[] — [{ id, text, revealed }]
isActive — boolean
--}}
<div class="npc-deck-container">
{{#if cards.length}}
{{#each cards}}
<div class="npc-card {{#if isActive}}active{{/if}}" data-actor-id="{{actorId}}">
{{!-- ── Card Header (always visible) ───────────────────────────────────── --}}
<div class="card-header">
<img class="card-portrait" src="{{actorImg}}" alt="{{actorName}}">
<span class="card-name">{{actorName}}</span>
<div class="card-header-actions">
<a class="open-actor-sheet" title="Open Actor Sheet">
<i class="fas fa-external-link-alt"></i>
</a>
<i class="fas fa-chevron-{{#if isActive}}up{{else}}down{{/if}} card-toggle-icon"></i>
</div>
</div>
{{!-- ── Card Body (only when expanded) ────────────────────────────────── --}}
{{#if isActive}}
<div class="card-body">
{{!-- Voice & Manner --}}
<section class="card-section voice-section">
<h4><i class="fas fa-microphone"></i> Voice &amp; Manner</h4>
<input
type="text"
class="card-field"
data-field="accent"
placeholder="Accent / region..."
value="{{accent}}">
<select class="card-field" data-field="speechQuirk">
<option value="">&#8212; Speech quirk &#8212;</option>
<option value="never-answers-directly" {{#if (eq speechQuirk "never-answers-directly") }}selected{{/if}}>Never answers directly</option>
<option value="speaks-in-questions" {{#if (eq speechQuirk "speaks-in-questions") }}selected{{/if}}>Speaks in questions</option>
<option value="excessive-use-of-titles" {{#if (eq speechQuirk "excessive-use-of-titles") }}selected{{/if}}>Excessive use of titles</option>
<option value="repeats-last-word" {{#if (eq speechQuirk "repeats-last-word") }}selected{{/if}}>Repeats last word</option>
<option value="long-pauses" {{#if (eq speechQuirk "long-pauses") }}selected{{/if}}>Long pauses</option>
<option value="talks-over-people" {{#if (eq speechQuirk "talks-over-people") }}selected{{/if}}>Talks over people</option>
<option value="whisperer" {{#if (eq speechQuirk "whisperer") }}selected{{/if}}>Whisperer</option>
<option value="overly-formal" {{#if (eq speechQuirk "overly-formal") }}selected{{/if}}>Overly formal</option>
<option value="custom" {{#if (eq speechQuirk "custom") }}selected{{/if}}>Custom...</option>
</select>
{{!-- Custom quirk free-text — only appears when "custom" is selected --}}
{{#if (eq speechQuirk "custom")}}
<input
type="text"
class="card-field"
data-field="speechQuirkCustom"
placeholder="Describe the quirk..."
value="{{speechQuirkCustom}}">
{{/if}}
<textarea
class="card-field"
data-field="mannerNote"
placeholder="Manner note &#8212; posture, gestures, eye contact..."
rows="3">{{mannerNote}}</textarea>
</section>
{{!-- Personality Tags --}}
<section class="card-section tags-section">
<h4><i class="fas fa-tags"></i> Personality Tags</h4>
<div class="tags-container">
{{#each tags}}
<span class="personality-tag tag-{{category}}" data-tag-index="{{@index}}">
{{text}}
<button class="tag-remove" data-tag-index="{{@index}}" title="Remove tag"
type="button">&#xd7;</button>
</span>
{{/each}}
</div>
<button type="button" class="add-tag-btn" data-actor-id="{{actorId}}">
+ Add Tag
</button>
</section>
{{!-- Attitude Tracker — iterates attitudesArray (pre-built, no {{lookup}}) --}}
{{#if attitudesArray.length}}
<section class="card-section attitude-section">
<h4><i class="fas fa-heart"></i> Attitude to PCs</h4>
{{#each attitudesArray}}
<div class="attitude-row">
<span class="attitude-pc-name" title="{{pcName}}">{{pcName}}</span>
<span class="attitude-label hostile">Hostile</span>
<input
type="range"
class="attitude-slider"
data-pc-id="{{pcId}}"
min="-3" max="3" step="1"
value="{{value}}">
<span class="attitude-label trusted">Trusted</span>
</div>
{{/each}}
</section>
{{/if}}
{{!-- Secrets --}}
<section class="card-section secrets-section">
<h4><i class="fas fa-user-secret"></i> Secrets</h4>
<div class="secrets-list">
{{#each secrets}}
<div class="secret-item {{#if revealed}}revealed{{/if}}">
<span class="secret-text">{{text}}</span>
<button
type="button"
class="secret-toggle"
data-secret-id="{{id}}"
title="{{#if revealed}}Mark as hidden{{else}}Mark as revealed{{/if}}">
<i class="fas fa-{{#if revealed}}eye{{else}}eye-slash{{/if}}"></i>
</button>
</div>
{{/each}}
</div>
<button type="button" class="add-secret-btn" data-actor-id="{{actorId}}">
+ Add Secret
</button>
</section>
</div>{{!-- /.card-body --}}
{{/if}}
</div>{{!-- /.npc-card --}}
{{/each}}
{{else}}
{{!-- Empty state --}}
<div class="deck-empty">
<i class="fas fa-address-card deck-empty-icon"></i>
<p>No NPC cards yet.</p>
<p class="deck-empty-hint">
Click any NPC token on the canvas and a card will be created automatically.
You can also open an NPC actor sheet to add data directly.
</p>
</div>
{{/if}}
</div>{{!-- /.npc-deck-container --}}
+28
View File
@@ -0,0 +1,28 @@
{{!--
card-tooltip.hbs
Reference template for the hover tooltip that appears near an NPC token.
NOTE: In the current implementation the tooltip is injected as raw DOM
(see NPCCardDeck.showTooltip) for speed and z-index reliability.
This template documents the intended structure and can be wired to a
Handlebars render call in a future refactor.
Expected context:
actorName — string
accent — string (may be empty)
quirk — string (resolved display label, may be empty)
topTags — string (up to 3 tags joined with " · ", may be empty)
--}}
<div class="npc-card-tooltip" role="tooltip">
<strong class="tooltip-name">{{actorName}}</strong>
{{#if accent}}
<em class="tooltip-accent">{{accent}}</em>
{{/if}}
{{#if quirk}}
<span class="tooltip-quirk">Quirk: {{quirk}}</span>
{{/if}}
{{#if topTags}}
<span class="tooltip-tags">{{topTags}}</span>
{{/if}}
</div>
+91
View File
@@ -0,0 +1,91 @@
{{!--
npc-card.hbs
Stand-alone single-card template.
Currently used as a reference / documentation template.
Can be wired to a future "full-screen card editor" ApplicationV2 window
by setting PARTS.card.template to this path and passing a single card
context object.
Expected context shape (same as _buildCardData output):
actorId, actorName, actorImg
accent, speechQuirk, speechQuirkCustom, mannerNote
tags[] — [{ text, category }]
attitudesArray[] — [{ pcId, pcName, value }]
secrets[] — [{ id, text, revealed }]
isActive — boolean (controls expand state if embedded in deck)
--}}
<div class="npc-card active standalone" data-actor-id="{{actorId}}">
<div class="card-header">
<img class="card-portrait" src="{{actorImg}}" alt="{{actorName}}">
<span class="card-name">{{actorName}}</span>
<div class="card-header-actions">
<a class="open-actor-sheet" title="Open Actor Sheet">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</div>
<div class="card-body">
<section class="card-section voice-section">
<h4><i class="fas fa-microphone"></i> Voice &amp; Manner</h4>
{{#if accent}}<p class="card-display-field">{{accent}}</p>{{/if}}
{{#if speechQuirk}}
<p class="card-display-field quirk-display">
Quirk:
{{#if (eq speechQuirk "custom")}}
{{speechQuirkCustom}}
{{else}}
{{speechQuirk}}
{{/if}}
</p>
{{/if}}
{{#if mannerNote}}<p class="card-display-field manner-display">{{mannerNote}}</p>{{/if}}
</section>
{{#if tags.length}}
<section class="card-section tags-section">
<h4><i class="fas fa-tags"></i> Personality Tags</h4>
<div class="tags-container">
{{#each tags}}
<span class="personality-tag tag-{{category}}">{{text}}</span>
{{/each}}
</div>
</section>
{{/if}}
{{#if attitudesArray.length}}
<section class="card-section attitude-section">
<h4><i class="fas fa-heart"></i> Attitude to PCs</h4>
{{#each attitudesArray}}
<div class="attitude-row">
<span class="attitude-pc-name">{{pcName}}</span>
<span class="attitude-label hostile">Hostile</span>
<input type="range" class="attitude-slider" data-pc-id="{{pcId}}"
min="-3" max="3" step="1" value="{{value}}">
<span class="attitude-label trusted">Trusted</span>
</div>
{{/each}}
</section>
{{/if}}
{{#if secrets.length}}
<section class="card-section secrets-section">
<h4><i class="fas fa-user-secret"></i> Secrets</h4>
{{#each secrets}}
<div class="secret-item {{#if revealed}}revealed{{/if}}">
<span class="secret-text">{{text}}</span>
<button type="button" class="secret-toggle" data-secret-id="{{id}}"
title="{{#if revealed}}Mark as hidden{{else}}Mark as revealed{{/if}}">
<i class="fas fa-{{#if revealed}}eye{{else}}eye-slash{{/if}}"></i>
</button>
</div>
{{/each}}
</section>
{{/if}}
</div>{{!-- /.card-body --}}
</div>