chore: initial deploy — SLA Industries Foundry VTT
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
._*
|
||||
*.swp
|
||||
worlds/
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
+31
@@ -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"
|
||||
}
|
||||
@@ -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 = [`<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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+109
@@ -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 = '<i class="fas fa-address-card"></i> 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 <header> element
|
||||
"header .action-buttons", // v13 variant
|
||||
".header-actions", // any version, loose
|
||||
".action-buttons", // any version, loose
|
||||
".directory-header", // fallback: whole header
|
||||
"header", // fallback: <header> 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);
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
--}}
|
||||
|
||||
<div class="npc-deck-container">
|
||||
|
||||
{{#if cards.length}}
|
||||
|
||||
{{#each cards}}
|
||||
<div class="npc-card {{#if isActive}}active{{/if}}" data-actor-id="{{actorId}}">
|
||||
|
||||
{{!-- ── Card Header (always visible) ───────────────────────────────────── --}}
|
||||
<div class="card-header">
|
||||
<img class="card-portrait" src="{{actorImg}}" alt="{{actorName}}">
|
||||
<span class="card-name">{{actorName}}</span>
|
||||
<div class="card-header-actions">
|
||||
<a class="open-actor-sheet" title="Open Actor Sheet">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
<i class="fas fa-chevron-{{#if isActive}}up{{else}}down{{/if}} card-toggle-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- ── Card Body (only when expanded) ────────────────────────────────── --}}
|
||||
{{#if isActive}}
|
||||
<div class="card-body">
|
||||
|
||||
{{!-- Voice & Manner --}}
|
||||
<section class="card-section voice-section">
|
||||
<h4><i class="fas fa-microphone"></i> Voice & Manner</h4>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="card-field"
|
||||
data-field="accent"
|
||||
placeholder="Accent / region..."
|
||||
value="{{accent}}">
|
||||
|
||||
<select class="card-field" data-field="speechQuirk">
|
||||
<option value="">— Speech quirk —</option>
|
||||
<option value="never-answers-directly" {{#if (eq speechQuirk "never-answers-directly") }}selected{{/if}}>Never answers directly</option>
|
||||
<option value="speaks-in-questions" {{#if (eq speechQuirk "speaks-in-questions") }}selected{{/if}}>Speaks in questions</option>
|
||||
<option value="excessive-use-of-titles" {{#if (eq speechQuirk "excessive-use-of-titles") }}selected{{/if}}>Excessive use of titles</option>
|
||||
<option value="repeats-last-word" {{#if (eq speechQuirk "repeats-last-word") }}selected{{/if}}>Repeats last word</option>
|
||||
<option value="long-pauses" {{#if (eq speechQuirk "long-pauses") }}selected{{/if}}>Long pauses</option>
|
||||
<option value="talks-over-people" {{#if (eq speechQuirk "talks-over-people") }}selected{{/if}}>Talks over people</option>
|
||||
<option value="whisperer" {{#if (eq speechQuirk "whisperer") }}selected{{/if}}>Whisperer</option>
|
||||
<option value="overly-formal" {{#if (eq speechQuirk "overly-formal") }}selected{{/if}}>Overly formal</option>
|
||||
<option value="custom" {{#if (eq speechQuirk "custom") }}selected{{/if}}>Custom...</option>
|
||||
</select>
|
||||
|
||||
{{!-- Custom quirk free-text — only appears when "custom" is selected --}}
|
||||
{{#if (eq speechQuirk "custom")}}
|
||||
<input
|
||||
type="text"
|
||||
class="card-field"
|
||||
data-field="speechQuirkCustom"
|
||||
placeholder="Describe the quirk..."
|
||||
value="{{speechQuirkCustom}}">
|
||||
{{/if}}
|
||||
|
||||
<textarea
|
||||
class="card-field"
|
||||
data-field="mannerNote"
|
||||
placeholder="Manner note — posture, gestures, eye contact..."
|
||||
rows="3">{{mannerNote}}</textarea>
|
||||
</section>
|
||||
|
||||
{{!-- Personality Tags --}}
|
||||
<section class="card-section tags-section">
|
||||
<h4><i class="fas fa-tags"></i> Personality Tags</h4>
|
||||
<div class="tags-container">
|
||||
{{#each tags}}
|
||||
<span class="personality-tag tag-{{category}}" data-tag-index="{{@index}}">
|
||||
{{text}}
|
||||
<button class="tag-remove" data-tag-index="{{@index}}" title="Remove tag"
|
||||
type="button">×</button>
|
||||
</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
<button type="button" class="add-tag-btn" data-actor-id="{{actorId}}">
|
||||
+ Add Tag
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{{!-- Attitude Tracker — iterates attitudesArray (pre-built, no {{lookup}}) --}}
|
||||
{{#if attitudesArray.length}}
|
||||
<section class="card-section attitude-section">
|
||||
<h4><i class="fas fa-heart"></i> Attitude to PCs</h4>
|
||||
{{#each attitudesArray}}
|
||||
<div class="attitude-row">
|
||||
<span class="attitude-pc-name" title="{{pcName}}">{{pcName}}</span>
|
||||
<span class="attitude-label hostile">Hostile</span>
|
||||
<input
|
||||
type="range"
|
||||
class="attitude-slider"
|
||||
data-pc-id="{{pcId}}"
|
||||
min="-3" max="3" step="1"
|
||||
value="{{value}}">
|
||||
<span class="attitude-label trusted">Trusted</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
{{!-- Secrets --}}
|
||||
<section class="card-section secrets-section">
|
||||
<h4><i class="fas fa-user-secret"></i> Secrets</h4>
|
||||
<div class="secrets-list">
|
||||
{{#each secrets}}
|
||||
<div class="secret-item {{#if revealed}}revealed{{/if}}">
|
||||
<span class="secret-text">{{text}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="secret-toggle"
|
||||
data-secret-id="{{id}}"
|
||||
title="{{#if revealed}}Mark as hidden{{else}}Mark as revealed{{/if}}">
|
||||
<i class="fas fa-{{#if revealed}}eye{{else}}eye-slash{{/if}}"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
<button type="button" class="add-secret-btn" data-actor-id="{{actorId}}">
|
||||
+ Add Secret
|
||||
</button>
|
||||
</section>
|
||||
|
||||
</div>{{!-- /.card-body --}}
|
||||
{{/if}}
|
||||
|
||||
</div>{{!-- /.npc-card --}}
|
||||
{{/each}}
|
||||
|
||||
{{else}}
|
||||
|
||||
{{!-- Empty state --}}
|
||||
<div class="deck-empty">
|
||||
<i class="fas fa-address-card deck-empty-icon"></i>
|
||||
<p>No NPC cards yet.</p>
|
||||
<p class="deck-empty-hint">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{/if}}
|
||||
|
||||
</div>{{!-- /.npc-deck-container --}}
|
||||
@@ -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)
|
||||
--}}
|
||||
|
||||
<div class="npc-card-tooltip" role="tooltip">
|
||||
<strong class="tooltip-name">{{actorName}}</strong>
|
||||
{{#if accent}}
|
||||
<em class="tooltip-accent">{{accent}}</em>
|
||||
{{/if}}
|
||||
{{#if quirk}}
|
||||
<span class="tooltip-quirk">Quirk: {{quirk}}</span>
|
||||
{{/if}}
|
||||
{{#if topTags}}
|
||||
<span class="tooltip-tags">{{topTags}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -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)
|
||||
--}}
|
||||
|
||||
<div class="npc-card active standalone" data-actor-id="{{actorId}}">
|
||||
|
||||
<div class="card-header">
|
||||
<img class="card-portrait" src="{{actorImg}}" alt="{{actorName}}">
|
||||
<span class="card-name">{{actorName}}</span>
|
||||
<div class="card-header-actions">
|
||||
<a class="open-actor-sheet" title="Open Actor Sheet">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
<section class="card-section voice-section">
|
||||
<h4><i class="fas fa-microphone"></i> Voice & Manner</h4>
|
||||
{{#if accent}}<p class="card-display-field">{{accent}}</p>{{/if}}
|
||||
{{#if speechQuirk}}
|
||||
<p class="card-display-field quirk-display">
|
||||
Quirk:
|
||||
{{#if (eq speechQuirk "custom")}}
|
||||
{{speechQuirkCustom}}
|
||||
{{else}}
|
||||
{{speechQuirk}}
|
||||
{{/if}}
|
||||
</p>
|
||||
{{/if}}
|
||||
{{#if mannerNote}}<p class="card-display-field manner-display">{{mannerNote}}</p>{{/if}}
|
||||
</section>
|
||||
|
||||
{{#if tags.length}}
|
||||
<section class="card-section tags-section">
|
||||
<h4><i class="fas fa-tags"></i> Personality Tags</h4>
|
||||
<div class="tags-container">
|
||||
{{#each tags}}
|
||||
<span class="personality-tag tag-{{category}}">{{text}}</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
{{#if attitudesArray.length}}
|
||||
<section class="card-section attitude-section">
|
||||
<h4><i class="fas fa-heart"></i> Attitude to PCs</h4>
|
||||
{{#each attitudesArray}}
|
||||
<div class="attitude-row">
|
||||
<span class="attitude-pc-name">{{pcName}}</span>
|
||||
<span class="attitude-label hostile">Hostile</span>
|
||||
<input type="range" class="attitude-slider" data-pc-id="{{pcId}}"
|
||||
min="-3" max="3" step="1" value="{{value}}">
|
||||
<span class="attitude-label trusted">Trusted</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
{{#if secrets.length}}
|
||||
<section class="card-section secrets-section">
|
||||
<h4><i class="fas fa-user-secret"></i> Secrets</h4>
|
||||
{{#each secrets}}
|
||||
<div class="secret-item {{#if revealed}}revealed{{/if}}">
|
||||
<span class="secret-text">{{text}}</span>
|
||||
<button type="button" class="secret-toggle" data-secret-id="{{id}}"
|
||||
title="{{#if revealed}}Mark as hidden{{else}}Mark as revealed{{/if}}">
|
||||
<i class="fas fa-{{#if revealed}}eye{{else}}eye-slash{{/if}}"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</section>
|
||||
{{/if}}
|
||||
|
||||
</div>{{!-- /.card-body --}}
|
||||
</div>
|
||||
Reference in New Issue
Block a user