Files
npc-quick-cards/scripts/card-deck.js
T

474 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});
}
}