commit 1804011b735f5f6dd66e40f0e4990bcbb46e3882 Author: Tim Evans (slaguru666) Date: Wed Apr 8 09:29:08 2026 +0100 chore: initial deploy — SLA Industries Foundry VTT diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8255ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.DS_Store +._* +*.swp +worlds/ diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..ca8d554 --- /dev/null +++ b/lang/en.json @@ -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)" + } +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..c087792 --- /dev/null +++ b/module.json @@ -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" +} \ No newline at end of file diff --git a/scripts/card-deck.js b/scripts/card-deck.js new file mode 100644 index 0000000..64b0782 --- /dev/null +++ b/scripts/card-deck.js @@ -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 = [`${actor.name}`]; + if (flags.accent) rows.push(`${flags.accent}`); + if (resolvedQuirk) rows.push(`Quirk: ${resolvedQuirk}`); + if (topTags) rows.push(topTags); + + tip.innerHTML = rows.join("
"); + + 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 => ``) + .join(""); + + return new Promise(resolve => { + new Dialog({ + title: "Add Personality Tag", + content: ` +
+
+ + +
+
+ + +
+
`, + buttons: { + add: { + icon: "", + 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: "", + 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: ` +
+
+ + +
+
`, + buttons: { + add: { + icon: "", + 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: "", + 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(); + }); + }); + } +} diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..4c7c49f --- /dev/null +++ b/scripts/main.js @@ -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 = ' 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
element + "header .action-buttons", // v13 variant + ".header-actions", // any version, loose + ".action-buttons", // any version, loose + ".directory-header", // fallback: whole header + "header", // fallback:
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); + } +}); diff --git a/scripts/npc-card.js b/scripts/npc-card.js new file mode 100644 index 0000000..8275ee7 --- /dev/null +++ b/scripts/npc-card.js @@ -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"; + } +} diff --git a/scripts/token-hooks.js b/scripts/token-hooks.js new file mode 100644 index 0000000..d495ab7 --- /dev/null +++ b/scripts/token-hooks.js @@ -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(); + } + }); +} diff --git a/styles/npc-quick-cards.css b/styles/npc-quick-cards.css new file mode 100644 index 0000000..4375c2d --- /dev/null +++ b/styles/npc-quick-cards.css @@ -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; +} diff --git a/templates/card-deck.hbs b/templates/card-deck.hbs new file mode 100644 index 0000000..21048ed --- /dev/null +++ b/templates/card-deck.hbs @@ -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 +--}} + +
+ + {{#if cards.length}} + + {{#each cards}} +
+ + {{!-- ── Card Header (always visible) ───────────────────────────────────── --}} +
+ {{actorName}} + {{actorName}} +
+ + + + +
+
+ + {{!-- ── Card Body (only when expanded) ────────────────────────────────── --}} + {{#if isActive}} +
+ + {{!-- Voice & Manner --}} +
+

Voice & Manner

+ + + + + + {{!-- Custom quirk free-text — only appears when "custom" is selected --}} + {{#if (eq speechQuirk "custom")}} + + {{/if}} + + +
+ + {{!-- Personality Tags --}} +
+

Personality Tags

+
+ {{#each tags}} + + {{text}} + + + {{/each}} +
+ +
+ + {{!-- Attitude Tracker — iterates attitudesArray (pre-built, no {{lookup}}) --}} + {{#if attitudesArray.length}} +
+

Attitude to PCs

+ {{#each attitudesArray}} +
+ {{pcName}} + Hostile + + Trusted +
+ {{/each}} +
+ {{/if}} + + {{!-- Secrets --}} +
+

Secrets

+
+ {{#each secrets}} +
+ {{text}} + +
+ {{/each}} +
+ +
+ +
{{!-- /.card-body --}} + {{/if}} + +
{{!-- /.npc-card --}} + {{/each}} + + {{else}} + + {{!-- Empty state --}} +
+ +

No NPC cards yet.

+

+ 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. +

+
+ + {{/if}} + +
{{!-- /.npc-deck-container --}} diff --git a/templates/card-tooltip.hbs b/templates/card-tooltip.hbs new file mode 100644 index 0000000..3f13c5c --- /dev/null +++ b/templates/card-tooltip.hbs @@ -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) +--}} + + diff --git a/templates/npc-card.hbs b/templates/npc-card.hbs new file mode 100644 index 0000000..19bec99 --- /dev/null +++ b/templates/npc-card.hbs @@ -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) +--}} + +
+ +
+ {{actorName}} + {{actorName}} +
+ + + +
+
+ +
+ +
+

Voice & Manner

+ {{#if accent}}

{{accent}}

{{/if}} + {{#if speechQuirk}} +

+ Quirk: + {{#if (eq speechQuirk "custom")}} + {{speechQuirkCustom}} + {{else}} + {{speechQuirk}} + {{/if}} +

+ {{/if}} + {{#if mannerNote}}

{{mannerNote}}

{{/if}} +
+ + {{#if tags.length}} +
+

Personality Tags

+
+ {{#each tags}} + {{text}} + {{/each}} +
+
+ {{/if}} + + {{#if attitudesArray.length}} +
+

Attitude to PCs

+ {{#each attitudesArray}} +
+ {{pcName}} + Hostile + + Trusted +
+ {{/each}} +
+ {{/if}} + + {{#if secrets.length}} +
+

Secrets

+ {{#each secrets}} +
+ {{text}} + +
+ {{/each}} +
+ {{/if}} + +
{{!-- /.card-body --}} +