474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
/**
|
||
* 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();
|
||
});
|
||
});
|
||
}
|
||
}
|