/**
* 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: `