/** * 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(); }); }); } }