Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8c9a0610 | ||
|
|
cd09c043b2 |
@@ -1,7 +1,13 @@
|
||||
# SLA Blueprint News (Foundry VTT module)
|
||||
|
||||
Generate SLA Industries **BPN** mission briefings inside Foundry. Each briefing
|
||||
creates **two separate journal entries**:
|
||||
creates a **folder ("package")** containing several separate journal entries:
|
||||
|
||||
- one **character-sheet entry per NPC** — a black-and-white "ident" portrait,
|
||||
who they are, their disposition, and why they matter (player handout).
|
||||
- one **card entry per OTEM item** — a line-art icon and a description.
|
||||
|
||||
plus the two core documents:
|
||||
|
||||
- **`… [Player]`** (ownership *Observer*) — a player document with two pages:
|
||||
- **Mission Brief** — the official corporate dispatch.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"id": "sla-blueprint-news",
|
||||
"title": "SLA Blueprint News",
|
||||
"description": "Generate SLA Industries BPN mission briefings as journal entries — a player-facing Brief and a separate GM-only Dossier, in the SLA house style with procedural artwork. Engine-agnostic core; this module is the Foundry wrapper.",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.361",
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
import { generateBPN, randomSeed } from "../vendor/core/bpn-core.js";
|
||||
import { COLOURS } from "../vendor/core/data/colours.js";
|
||||
import { framesForColour } from "../vendor/core/data/frames.js";
|
||||
import { briefHTML, fieldDossierHTML, dossierHTML } from "../vendor/render/bpn-render.js";
|
||||
import { crestDataURI } from "../vendor/art/bpn-art.js";
|
||||
import { briefHTML, fieldDossierHTML, dossierHTML, npcCardHTML, itemCardHTML } from "../vendor/render/bpn-render.js";
|
||||
import { crestDataURI, npcPortraitDataURI, itemIconDataURI } from "../vendor/art/bpn-art.js";
|
||||
|
||||
const MODULE_ID = "sla-blueprint-news";
|
||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
@@ -171,16 +171,19 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
|
||||
const bpn = this._bpn();
|
||||
const O = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
const HTML = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
|
||||
const accent = bpn.colour.accent;
|
||||
const wrap = (html) => `<div class="bpn">${html}</div>`;
|
||||
const base = `${bpn.colour.label} BPN — ${bpn.shortTitle}`;
|
||||
const flags = { [MODULE_ID]: { seed: bpn.seed, colourKey: bpn.colourKey, frameId: bpn.gm.frameId, sclLevel: bpn.sclLevel } };
|
||||
const page = (name, content, owner) => ({ name, type: "text", title: { show: false, level: 1 }, ownership: { default: owner }, text: { format: HTML, content } });
|
||||
|
||||
// Separate player document: the Mission Brief + the Field Dossier handout.
|
||||
// A folder is the "package" that holds the whole briefing's documents.
|
||||
const folder = await Folder.create({ name: base, type: "JournalEntry", color: accent });
|
||||
const fid = folder.id;
|
||||
|
||||
// Separate player document: Mission Brief + Field Dossier handout.
|
||||
const playerEntry = await JournalEntry.create({
|
||||
name: `${base} [Player]`,
|
||||
ownership: { default: O.OBSERVER },
|
||||
flags,
|
||||
name: `${base} [Player]`, folder: fid, ownership: { default: O.OBSERVER }, flags,
|
||||
pages: [
|
||||
page("Mission Brief", wrap(briefHTML(bpn, ART)), O.OBSERVER),
|
||||
page("Field Dossier", wrap(fieldDossierHTML(bpn, ART)), O.OBSERVER)
|
||||
@@ -188,17 +191,31 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
|
||||
});
|
||||
|
||||
// Separate GM-only document, linked back to the player document.
|
||||
const gmEntry = await JournalEntry.create({
|
||||
name: `${base} [GM]`,
|
||||
ownership: { default: O.NONE },
|
||||
flags,
|
||||
pages: [
|
||||
page("GM Dossier", wrap(dossierHTML(bpn, ART)) + `<p class="bpn-rolls">Player document: @UUID[${playerEntry.uuid}]{${base} [Player]}</p>`, O.NONE)
|
||||
]
|
||||
await JournalEntry.create({
|
||||
name: `${base} [GM]`, folder: fid, ownership: { default: O.NONE }, flags,
|
||||
pages: [page("GM Dossier", wrap(dossierHTML(bpn, ART)) + `<p class="bpn-rolls">Player document: @UUID[${playerEntry.uuid}]{${base} [Player]}</p>`, O.NONE)]
|
||||
});
|
||||
|
||||
ui.notifications.info(`Created "${base}": a player document (Brief + Field Dossier) and a separate GM document.`);
|
||||
gmEntry.sheet?.render(true);
|
||||
// One character-sheet document per NPC (player handout, portrait + sheet).
|
||||
const npcEntries = bpn.cast.map((npc) => ({
|
||||
name: npc.name, folder: fid,
|
||||
ownership: { default: O.OBSERVER },
|
||||
flags: { [MODULE_ID]: { kind: "npc", seed: npc.seed, bpn: bpn.seed } },
|
||||
pages: [page("Character Sheet", wrap(npcCardHTML(npc, { artMode: "img", accent })), O.OBSERVER)]
|
||||
}));
|
||||
|
||||
// One card document per item (player handout, icon + description).
|
||||
const itemEntries = bpn.otem.map((item) => ({
|
||||
name: item.name, folder: fid,
|
||||
ownership: { default: O.OBSERVER },
|
||||
flags: { [MODULE_ID]: { kind: "otem", seed: item.seed, bpn: bpn.seed } },
|
||||
pages: [page(item.name, wrap(itemCardHTML(item, { artMode: "img", accent })), O.OBSERVER)]
|
||||
}));
|
||||
|
||||
await JournalEntry.createDocuments([...npcEntries, ...itemEntries]);
|
||||
|
||||
ui.notifications.info(`Created package "${base}": player + GM documents, ${bpn.cast.length} NPC sheets and ${bpn.otem.length} item cards in a folder.`);
|
||||
playerEntry.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async #onChat() {
|
||||
|
||||
+18
-13
@@ -33,27 +33,32 @@ Hooks.on("renderJournalDirectory", (_app, html) => {
|
||||
header.appendChild(btn);
|
||||
});
|
||||
|
||||
/* Notes-layer toolbar button as a second entry point (v13/v14 control shape). */
|
||||
/* Token-group toolbar button (GM only). v14 controls is a keyed object and
|
||||
tools fire via onChange; v13 is an array. */
|
||||
Hooks.on("getSceneControlButtons", (controls) => {
|
||||
if (!game.user?.isGM) return;
|
||||
const open = () => BlueprintNewsApp.open();
|
||||
const tool = {
|
||||
name: "slaBpnOpen",
|
||||
title: game.i18n.localize("SLABPN.ToolbarOpen"),
|
||||
icon: "fas fa-file-contract",
|
||||
visible: true,
|
||||
button: true,
|
||||
onClick: () => BlueprintNewsApp.open()
|
||||
toggle: false,
|
||||
onChange: open,
|
||||
onClick: open
|
||||
};
|
||||
if (controls && typeof controls === "object" && !Array.isArray(controls)) {
|
||||
const notes = controls.notes;
|
||||
if (notes) {
|
||||
notes.tools ??= {};
|
||||
notes.tools[tool.name] ??= tool;
|
||||
}
|
||||
} else if (Array.isArray(controls)) {
|
||||
const notes = controls.find((c) => c.name === "notes");
|
||||
if (notes) {
|
||||
notes.tools ??= [];
|
||||
if (!notes.tools.some((t) => t.name === tool.name)) notes.tools.push(tool);
|
||||
if (controls && !Array.isArray(controls)) {
|
||||
const grp = controls.tokens ?? controls.token ?? null;
|
||||
if (grp) {
|
||||
grp.tools ??= {};
|
||||
grp.tools[tool.name] = tool;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const grp = controls.find((c) => c.name === "token" || c.name === "tokens");
|
||||
if (grp) {
|
||||
if (Array.isArray(grp.tools)) grp.tools.push(tool);
|
||||
else { grp.tools ??= {}; grp.tools[tool.name] = tool; }
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+113
@@ -331,6 +331,113 @@ export function portraitSVG(bpn, { size = 120 } = {}) {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* NPC PORTRAIT — a simple black-and-white "ident" mugshot, procedural and */
|
||||
/* deterministic per NPC. Stencil ink line-art: head, hair/hood/helmet, eyes */
|
||||
/* or visor, mouth or breather, an accessory, light hatch shading. Greyscale */
|
||||
/* only; the colour band shows just as a thin tag bar. */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export function npcPortraitSVG(npc, { size = 168, accent = "#9fb0c4" } = {}) {
|
||||
const seed = npc?.seed ?? npc?.name ?? "npc";
|
||||
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "n";
|
||||
const rng = rngFor(seed, "face");
|
||||
const ink = "#e9eef4";
|
||||
const dark = "#0d1117";
|
||||
const skin = "#1b212b";
|
||||
const stroke = (w = 2) => `fill="none" stroke="${ink}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"`;
|
||||
|
||||
const choose = (arr) => arr[Math.floor(rng() * arr.length)];
|
||||
const rx = choose([18, 20, 22]);
|
||||
const ry = choose([25, 28, 32]);
|
||||
const top = choose(["hairShort", "hairCrop", "bald", "hood", "helmet", "cap"]);
|
||||
const eyes = choose(["plain", "visor", "goggles", "augmetic"]);
|
||||
const mouth = choose(["neutral", "grim", "breather", "smirk"]);
|
||||
const extra = choose(["none", "earpiece", "scar", "collar"]);
|
||||
const cx = 84, cy = 78; // head centre in a 168x190 space
|
||||
|
||||
const head = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${skin}" stroke="${ink}" stroke-width="2"/>`;
|
||||
const jaw = `<path d="M${cx - rx + 2} ${cy + ry - 8} Q${cx} ${cy + ry + 6} ${cx + rx - 2} ${cy + ry - 8}" ${stroke(1.5)}/>`;
|
||||
|
||||
let hair = "";
|
||||
if (top === "hairShort") hair = `<path d="M${cx - rx - 2} ${cy - 6} Q${cx} ${cy - ry - 12} ${cx + rx + 2} ${cy - 6} Q${cx + rx - 6} ${cy - ry + 4} ${cx} ${cy - ry + 2} Q${cx - rx + 6} ${cy - ry + 4} ${cx - rx - 2} ${cy - 6} Z" fill="${ink}" opacity="0.9"/>`;
|
||||
else if (top === "hairCrop") hair = `<path d="M${cx - rx} ${cy - ry + 8} Q${cx} ${cy - ry - 4} ${cx + rx} ${cy - ry + 8}" ${stroke(5)}/>`;
|
||||
else if (top === "bald") hair = `<path d="M${cx - 8} ${cy - ry + 4} Q${cx} ${cy - ry - 2} ${cx + 8} ${cy - ry + 4}" ${stroke(1)} opacity="0.5"/>`;
|
||||
else if (top === "hood") hair = `<path d="M${cx - rx - 10} ${cy + 8} Q${cx - rx - 12} ${cy - ry - 18} ${cx} ${cy - ry - 20} Q${cx + rx + 12} ${cy - ry - 18} ${cx + rx + 10} ${cy + 8}" ${stroke(2.5)}/>`;
|
||||
else if (top === "helmet") hair = `<path d="M${cx - rx - 3} ${cy - 2} Q${cx} ${cy - ry - 14} ${cx + rx + 3} ${cy - 2}" ${stroke(3)}/><line x1="${cx}" y1="${cy - ry - 10}" x2="${cx}" y2="${cy - 4}" ${stroke(1.5)}/>`;
|
||||
else if (top === "cap") hair = `<path d="M${cx - rx - 6} ${cy - ry + 10} H${cx + rx + 2} Q${cx} ${cy - ry - 8} ${cx - rx - 6} ${cy - ry + 10} Z" fill="${ink}" opacity="0.85"/>`;
|
||||
|
||||
const ey = cy - 4;
|
||||
let eyeMarks = "";
|
||||
if (eyes === "plain") eyeMarks = `<line x1="${cx - 13}" y1="${ey}" x2="${cx - 6}" y2="${ey}" ${stroke(2)}/><line x1="${cx + 6}" y1="${ey}" x2="${cx + 13}" y2="${ey}" ${stroke(2)}/><line x1="${cx - 14}" y1="${ey - 6}" x2="${cx - 5}" y2="${ey - 7}" ${stroke(1.2)}/><line x1="${cx + 5}" y1="${ey - 7}" x2="${cx + 14}" y2="${ey - 6}" ${stroke(1.2)}/>`;
|
||||
else if (eyes === "visor") eyeMarks = `<rect x="${cx - 16}" y="${ey - 4}" width="32" height="8" rx="4" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx - 12}" y1="${ey}" x2="${cx + 12}" y2="${ey}" stroke="${ink}" stroke-width="1" opacity="0.5"/>`;
|
||||
else if (eyes === "goggles") eyeMarks = `<circle cx="${cx - 9}" cy="${ey}" r="5.5" ${stroke(2)}/><circle cx="${cx + 9}" cy="${ey}" r="5.5" ${stroke(2)}/><line x1="${cx - 3.5}" y1="${ey}" x2="${cx + 3.5}" y2="${ey}" ${stroke(2)}/>`;
|
||||
else eyeMarks = `<line x1="${cx - 13}" y1="${ey}" x2="${cx - 6}" y2="${ey}" ${stroke(2)}/><rect x="${cx + 5}" y="${ey - 4}" width="10" height="8" rx="1.5" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx + 7}" y1="${ey}" x2="${cx + 13}" y2="${ey}" stroke="${ink}" stroke-width="1"/>`;
|
||||
|
||||
const nose = `<path d="M${cx} ${ey + 4} L${cx - 3} ${cy + 8} L${cx + 2} ${cy + 8}" ${stroke(1.4)}/>`;
|
||||
|
||||
const my = cy + 17;
|
||||
let mouthMark = "";
|
||||
if (mouth === "neutral") mouthMark = `<line x1="${cx - 7}" y1="${my}" x2="${cx + 7}" y2="${my}" ${stroke(1.8)}/>`;
|
||||
else if (mouth === "grim") mouthMark = `<path d="M${cx - 8} ${my - 1} Q${cx} ${my + 3} ${cx + 8} ${my - 1}" ${stroke(1.8)}/>`;
|
||||
else if (mouth === "smirk") mouthMark = `<path d="M${cx - 7} ${my + 1} Q${cx} ${my - 1} ${cx + 8} ${my - 3}" ${stroke(1.8)}/>`;
|
||||
else mouthMark = `<rect x="${cx - 12}" y="${my - 7}" width="24" height="15" rx="4" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx - 12}" y1="${my}" x2="${cx - 22}" y2="${my - 4}" ${stroke(1.5)}/><line x1="${cx + 12}" y1="${my}" x2="${cx + 22}" y2="${my - 4}" ${stroke(1.5)}/><circle cx="${cx}" cy="${my}" r="2" fill="${ink}"/>`;
|
||||
|
||||
let extraMark = "";
|
||||
if (extra === "earpiece") extraMark = `<circle cx="${cx + rx - 1}" cy="${ey + 4}" r="3" ${stroke(1.5)}/><line x1="${cx + rx + 1}" y1="${ey + 6}" x2="${cx + rx + 6}" y2="${ey + 14}" ${stroke(1.5)}/>`;
|
||||
else if (extra === "scar") extraMark = `<line x1="${cx - 12}" y1="${cy - 14}" x2="${cx - 4}" y2="${cy + 6}" ${stroke(1.5)}/>`;
|
||||
else if (extra === "collar") extraMark = `<path d="M${cx - 22} 168 L${cx - 8} ${cy + ry + 6} M${cx + 22} 168 L${cx + 8} ${cy + ry + 6}" ${stroke(2)}/>`;
|
||||
|
||||
// light hatch shading down the right cheek
|
||||
const hatch = Array.from({ length: 4 }, (_, i) =>
|
||||
`<line x1="${cx + 6 + i * 3}" y1="${cy - 6 + i * 2}" x2="${cx + 10 + i * 3}" y2="${cy + 10 + i * 2}" stroke="${ink}" stroke-width="1" opacity="0.18"/>`).join("");
|
||||
|
||||
const shoulders = `<path d="M22 190 V164 C22 142 48 130 84 130 C120 130 146 142 146 164 V190 Z" fill="${skin}" stroke="${ink}" stroke-width="2"/>`;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 190" width="${size}" height="${Math.round(size * 190 / 168)}" role="img" aria-label="${npc?.name ?? "ident"} portrait">
|
||||
<rect x="2" y="2" width="164" height="186" rx="10" fill="${dark}" stroke="${rgba(accent, 0.45)}" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="164" height="16" rx="10" fill="${rgba(accent, 0.18)}"/>
|
||||
<text x="10" y="14" font-family="Roboto Mono, monospace" font-size="9" letter-spacing="2" fill="${rgba(accent, 0.85)}">IDENT</text>
|
||||
${shoulders}
|
||||
${extra === "collar" ? extraMark : ""}
|
||||
${head}${jaw}${hatch}
|
||||
${hair}
|
||||
${eyeMarks}${nose}${mouthMark}
|
||||
${extra !== "collar" ? extraMark : ""}
|
||||
<line x1="10" y1="178" x2="158" y2="178" stroke="${rgba(accent, 0.3)}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* ITEM ICON — simple black-and-white line-art keyed to the OTEM category. */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export function itemIconSVG(item, { size = 120, accent = "#9fb0c4" } = {}) {
|
||||
const ink = "#e9eef4";
|
||||
const dark = "#0d1117";
|
||||
const s = (w = 2.4) => `fill="none" stroke="${ink}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"`;
|
||||
const cat = item?.category ?? "slate";
|
||||
const glyphs = {
|
||||
slate: `<rect x="34" y="24" width="32" height="52" rx="4" ${s()}/><line x1="40" y1="36" x2="60" y2="36" ${s(1.6)}/><line x1="40" y1="44" x2="60" y2="44" ${s(1.6)}/><line x1="40" y1="52" x2="54" y2="52" ${s(1.6)}/><circle cx="50" cy="68" r="2.2" fill="${ink}"/>`,
|
||||
canister: `<rect x="38" y="26" width="24" height="48" rx="6" ${s()}/><ellipse cx="50" cy="26" rx="12" ry="4" ${s(1.6)}/><line x1="38" y1="46" x2="62" y2="46" ${s(1.6)}/><rect x="44" y="18" width="12" height="8" rx="2" ${s(1.6)}/>`,
|
||||
weapon: `<path d="M28 44 H64 V54 H50 L46 64 H40 L42 54 H28 Z" ${s()}/><line x1="34" y1="44" x2="34" y2="38" ${s(1.6)}/>`,
|
||||
chip: `<rect x="34" y="34" width="32" height="32" rx="3" ${s()}/><rect x="42" y="42" width="16" height="16" rx="2" ${s(1.6)}/><line x1="38" y1="30" x2="38" y2="34" ${s(1.4)}/><line x1="50" y1="30" x2="50" y2="34" ${s(1.4)}/><line x1="62" y1="30" x2="62" y2="34" ${s(1.4)}/><line x1="38" y1="66" x2="38" y2="70" ${s(1.4)}/><line x1="62" y1="66" x2="62" y2="70" ${s(1.4)}/>`,
|
||||
crate: `<rect x="28" y="32" width="44" height="36" rx="3" ${s()}/><line x1="28" y1="50" x2="72" y2="50" ${s(1.6)}/><line x1="50" y1="32" x2="50" y2="68" ${s(1.6)}/><circle cx="34" cy="38" r="1.8" fill="${ink}"/><circle cx="66" cy="38" r="1.8" fill="${ink}"/>`,
|
||||
bug: `<ellipse cx="50" cy="52" rx="10" ry="14" ${s()}/><circle cx="50" cy="36" r="5" ${s(1.6)}/><line x1="47" y1="32" x2="44" y2="26" ${s(1.4)}/><line x1="53" y1="32" x2="56" y2="26" ${s(1.4)}/><line x1="40" y1="46" x2="30" y2="42" ${s(1.4)}/><line x1="40" y1="56" x2="30" y2="58" ${s(1.4)}/><line x1="60" y1="46" x2="70" y2="42" ${s(1.4)}/><line x1="60" y1="56" x2="70" y2="58" ${s(1.4)}/>`,
|
||||
component: `<circle cx="50" cy="50" r="14" ${s()}/><circle cx="50" cy="50" r="5" ${s(1.6)}/><g stroke="${ink}" stroke-width="2.4" stroke-linecap="round">${Array.from({ length: 8 }, (_, i) => { const a = i / 8 * Math.PI * 2; return `<line x1="${50 + Math.cos(a) * 14}" y1="${50 + Math.sin(a) * 14}" x2="${50 + Math.cos(a) * 19}" y2="${50 + Math.sin(a) * 19}"/>`; }).join("")}</g>`,
|
||||
relay: `<line x1="50" y1="30" x2="50" y2="70" ${s()}/><rect x="44" y="68" width="12" height="6" ${s(1.6)}/><path d="M40 34 Q50 24 60 34" ${s(1.6)}/><path d="M36 30 Q50 16 64 30" ${s(1.4)}/><circle cx="50" cy="30" r="2.5" fill="${ink}"/>`,
|
||||
kit: `<rect x="30" y="36" width="40" height="30" rx="4" ${s()}/><line x1="42" y1="36" x2="42" y2="30" ${s(1.6)}/><line x1="58" y1="36" x2="58" y2="30" ${s(1.6)}/><line x1="50" y1="44" x2="50" y2="58" ${s(2)}/><line x1="43" y1="51" x2="57" y2="51" ${s(2)}/>`,
|
||||
drone: `<rect x="44" y="44" width="12" height="12" rx="2" ${s()}/><line x1="44" y1="44" x2="32" y2="32" ${s(1.6)}/><line x1="56" y1="44" x2="68" y2="32" ${s(1.6)}/><line x1="44" y1="56" x2="32" y2="68" ${s(1.6)}/><line x1="56" y1="56" x2="68" y2="68" ${s(1.6)}/><circle cx="30" cy="30" r="4" ${s(1.6)}/><circle cx="70" cy="30" r="4" ${s(1.6)}/><circle cx="30" cy="70" r="4" ${s(1.6)}/><circle cx="70" cy="70" r="4" ${s(1.6)}/>`,
|
||||
key: `<circle cx="38" cy="50" r="10" ${s()}/><line x1="48" y1="50" x2="72" y2="50" ${s()}/><line x1="66" y1="50" x2="66" y2="58" ${s(1.8)}/><line x1="72" y1="50" x2="72" y2="60" ${s(1.8)}/>`,
|
||||
card: `<rect x="28" y="36" width="44" height="30" rx="3" ${s()}/><rect x="33" y="42" width="12" height="14" rx="1.5" ${s(1.6)}/><line x1="50" y1="44" x2="66" y2="44" ${s(1.4)}/><line x1="50" y1="50" x2="66" y2="50" ${s(1.4)}/><line x1="50" y1="56" x2="60" y2="56" ${s(1.4)}/>`
|
||||
};
|
||||
const inner = glyphs[cat] ?? glyphs.slate;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${size}" height="${size}" role="img" aria-label="${item?.name ?? "item"} icon">
|
||||
<rect x="2" y="2" width="96" height="96" rx="10" fill="${dark}" stroke="${rgba(accent, 0.45)}" stroke-width="2"/>
|
||||
${inner}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* SITE MAP — a schematic, non-photorealistic sector diagram for the Field */
|
||||
/* Dossier. Numbered nodes (one per location) wired along a route over a */
|
||||
@@ -397,3 +504,9 @@ export function portraitDataURI(bpn, opts) {
|
||||
export function siteMapDataURI(bpn, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(siteMapSVG(bpn, opts))}`;
|
||||
}
|
||||
export function npcPortraitDataURI(npc, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(npcPortraitSVG(npc, opts))}`;
|
||||
}
|
||||
export function itemIconDataURI(item, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(itemIconSVG(item, opts))}`;
|
||||
}
|
||||
|
||||
Vendored
+65
-28
@@ -266,36 +266,72 @@ function ucFirst(s = "") {
|
||||
return v.charAt(0).toUpperCase() + v.slice(1);
|
||||
}
|
||||
|
||||
/** Build the cast, OTEM and locations for a briefing. Each entry carries a
|
||||
* player-facing `player` line and a GM-only `gm` secret. */
|
||||
function buildDetail(rng, ctx) {
|
||||
const cast = [];
|
||||
// The on-site contact and the reported threat are always part of the cast.
|
||||
cast.push({
|
||||
name: ctx.contactName,
|
||||
role: ctx.contactRole,
|
||||
player: `On-site contact, holding at ${lcFirst(trimEnd(ctx.contactLocation))}.`,
|
||||
gm: pick(rng, DETAIL.npcSecrets)
|
||||
});
|
||||
cast.push({
|
||||
name: ctx.threatLead || "Reported hostile",
|
||||
role: "Designated threat",
|
||||
player: ctx.threatCount || "Hostile lead flagged by command.",
|
||||
gm: ctx.oppositionAttitude ? `Really: ${lcFirst(ctx.oppositionAttitude)}` : pick(rng, DETAIL.npcSecrets)
|
||||
});
|
||||
// Plus two generated supporting NPCs.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const role = pick(rng, DETAIL.npcRoles);
|
||||
cast.push({
|
||||
name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}`,
|
||||
role,
|
||||
player: `${pick(rng, DETAIL.npcDispositions)} ${role.toLowerCase()}. ${pick(rng, DETAIL.npcPlayerNotes)}`,
|
||||
gm: pick(rng, DETAIL.npcSecrets)
|
||||
});
|
||||
}
|
||||
/** Classify an OTEM name into an icon category for the line-art item icon. */
|
||||
function otemCategory(name = "") {
|
||||
const n = String(name).toLowerCase();
|
||||
if (n.includes("slate") || n.includes("data")) return "slate";
|
||||
if (n.includes("canister") || n.includes("sample")) return "canister";
|
||||
if (n.includes("weapon") || n.includes("cache")) return "weapon";
|
||||
if (n.includes("chip") || n.includes("credential")) return "chip";
|
||||
if (n.includes("crate") || n.includes("evidence")) return "crate";
|
||||
if (n.includes("bug") || n.includes("surveillance")) return "bug";
|
||||
if (n.includes("component") || n.includes("prototype")) return "component";
|
||||
if (n.includes("relay") || n.includes("comms")) return "relay";
|
||||
if (n.includes("med") || n.includes("kit")) return "kit";
|
||||
if (n.includes("drone") || n.includes("camera")) return "drone";
|
||||
if (n.includes("key")) return "key";
|
||||
if (n.includes("card") || n.includes("id")) return "card";
|
||||
return "slate";
|
||||
}
|
||||
|
||||
const otem = sample(rng, DETAIL.otemNames, 3).map((name) => ({
|
||||
/** Build the cast, OTEM and locations. Each NPC is a small character sheet
|
||||
* (description, significance, appearance + a stable seed for its portrait);
|
||||
* each item carries a description + category for its line-art icon. Player
|
||||
* facing fields plus a GM-only `gm` secret. */
|
||||
function buildDetail(rng, ctx) {
|
||||
const makeNpc = (i, base) => {
|
||||
const build = pick(rng, DETAIL.npcBuilds);
|
||||
const feature = pick(rng, DETAIL.npcFeatures);
|
||||
const habit = pick(rng, DETAIL.npcHabits);
|
||||
const role = base.role ?? pick(rng, DETAIL.npcRoles);
|
||||
return {
|
||||
seed: `${ctx.seed}::npc::${i}`,
|
||||
name: base.name,
|
||||
role,
|
||||
disposition: base.disposition ?? pick(rng, DETAIL.npcDispositions),
|
||||
appearance: { build, feature, habit },
|
||||
description: `${ucFirst(build)} ${role.toLowerCase()} with ${feature}; ${habit}.`,
|
||||
significance: base.significance ?? pick(rng, DETAIL.npcSignificance),
|
||||
player: base.player ?? pick(rng, DETAIL.npcPlayerNotes),
|
||||
gm: base.gm ?? pick(rng, DETAIL.npcSecrets)
|
||||
};
|
||||
};
|
||||
|
||||
const cast = [
|
||||
makeNpc(0, {
|
||||
name: ctx.contactName,
|
||||
role: ctx.contactRole,
|
||||
disposition: "Cooperative",
|
||||
significance: "The squad's authorised contact on the ground.",
|
||||
player: `On-site contact, holding at ${lcFirst(trimEnd(ctx.contactLocation))}.`
|
||||
}),
|
||||
makeNpc(1, {
|
||||
name: ctx.threatLead || "Reported hostile",
|
||||
role: "Designated threat",
|
||||
disposition: "Openly hostile",
|
||||
significance: "The hostile command flagged as the squad's primary problem.",
|
||||
player: ctx.threatCount || "Hostile lead flagged by command.",
|
||||
gm: ctx.oppositionAttitude ? `Really: ${lcFirst(ctx.oppositionAttitude)}` : pick(rng, DETAIL.npcSecrets)
|
||||
}),
|
||||
makeNpc(2, { name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` }),
|
||||
makeNpc(3, { name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` })
|
||||
];
|
||||
|
||||
const otem = sample(rng, DETAIL.otemNames, 3).map((name, i) => ({
|
||||
seed: `${ctx.seed}::otem::${i}`,
|
||||
name,
|
||||
category: otemCategory(name),
|
||||
description: pick(rng, DETAIL.otemDescriptions),
|
||||
player: pick(rng, DETAIL.otemPlayerNotes),
|
||||
gm: pick(rng, DETAIL.otemSecrets)
|
||||
}));
|
||||
@@ -449,6 +485,7 @@ export function generateBPN(opts = {}) {
|
||||
// Expanded detail for the separate player Field Dossier (player lines) and
|
||||
// the GM Dossier (secrets). Built last so it can read the resolved cast.
|
||||
const detail = buildDetail(rng, {
|
||||
seed,
|
||||
contactName: bpn.contact.name,
|
||||
contactRole: bpn.contact.role,
|
||||
contactLocation: bpn.contact.location,
|
||||
|
||||
Vendored
+38
@@ -39,6 +39,44 @@ export const DETAIL = {
|
||||
"Plans to loot the site the moment the shooting starts.",
|
||||
"Is the actual target the GM is steering the mission toward."
|
||||
],
|
||||
npcBuilds: [
|
||||
"tall and gaunt", "short and wiry", "broad and scarred", "lean and twitchy",
|
||||
"heavyset and unhurried", "compact and precise", "stooped and tired", "rangy and restless"
|
||||
],
|
||||
npcFeatures: [
|
||||
"a battered breather mask", "a mirrored visor", "a shaved, tattooed scalp",
|
||||
"a long oilcloth coat", "a crude augmetic eye", "cropped iron-grey hair",
|
||||
"a jaw brace and old burns", "corporate-neat hair and a dead stare",
|
||||
"a hood pulled low", "a wired earpiece they keep touching"
|
||||
],
|
||||
npcHabits: [
|
||||
"never stops watching the exits", "talks too fast when cornered",
|
||||
"keeps one hand near a concealed weapon", "quotes regulations at everyone",
|
||||
"won't make eye contact", "smiles at exactly the wrong moments",
|
||||
"flinches at every comms burst", "counts the squad's gear without thinking"
|
||||
],
|
||||
npcSignificance: [
|
||||
"Holds the access the squad needs to move at all.",
|
||||
"Knows the layout better than the map command supplied.",
|
||||
"Can vouch for the squad — or burn them with one word.",
|
||||
"Is the only working line back to the contact.",
|
||||
"Controls who gets in and out of the site.",
|
||||
"Has eyes on the objective right now.",
|
||||
"Is the loose thread the whole job hangs on.",
|
||||
"Will be the first casualty if things go loud."
|
||||
],
|
||||
|
||||
/* ---- item descriptions ---- */
|
||||
otemDescriptions: [
|
||||
"Standard-issue casing, non-standard contents — somebody went to trouble over this.",
|
||||
"Looks ordinary until you notice the tamper seals have been replaced twice.",
|
||||
"Heavier than it should be, and warm to the touch.",
|
||||
"Marked with a department code that was retired years ago.",
|
||||
"Scuffed and field-worn, clearly passed through several hands.",
|
||||
"Pristine, sealed, and very obviously expensive.",
|
||||
"Jury-rigged from three other things that were never meant to meet.",
|
||||
"Quiet now, but the indicator light says it is still doing something."
|
||||
],
|
||||
|
||||
/* ---- OTEM (items & equipment) ---- */
|
||||
otemNames: [
|
||||
|
||||
Vendored
+29
-13
@@ -12,8 +12,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
crestSVG, bannerSVG, portraitSVG, siteMapSVG,
|
||||
crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI
|
||||
crestSVG, bannerSVG, portraitSVG, siteMapSVG, npcPortraitSVG, itemIconSVG,
|
||||
crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI, npcPortraitDataURI, itemIconDataURI
|
||||
} from "../art/bpn-art.js";
|
||||
|
||||
function esc(value) {
|
||||
@@ -125,18 +125,34 @@ export function briefHTML(bpn, { art = true, artMode = "inline" } = {}) {
|
||||
are cleared to know, with a schematic site map. The GM-only secrets for the
|
||||
same entries live in the GM Dossier. */
|
||||
|
||||
function castRow(n) {
|
||||
return `<div class="bpn-detail-item">
|
||||
<div class="bpn-detail-item__name">${esc(n.name)}</div>
|
||||
<div class="bpn-detail-item__role">${esc(n.role)}</div>
|
||||
<div class="bpn-detail-item__line">${esc(n.player)}</div>
|
||||
/** A simple NPC character-sheet card: portrait, who they are, why they matter. */
|
||||
export function npcCardHTML(npc, { artMode = "inline", accent = "#9fb0c4" } = {}) {
|
||||
const art = artMode === "img"
|
||||
? `<img src="${npcPortraitDataURI(npc, { size: 168, accent })}" alt="${esc(npc.name)}">`
|
||||
: npcPortraitSVG(npc, { size: 168, accent });
|
||||
return `<div class="bpn-npc-card">
|
||||
<div class="bpn-npc-card__art">${art}</div>
|
||||
<div class="bpn-npc-card__body">
|
||||
<div class="bpn-npc-card__name">${esc(npc.name)}</div>
|
||||
<div class="bpn-npc-card__role">${esc(npc.role)} · ${esc(npc.disposition)}</div>
|
||||
<p class="bpn-npc-card__desc">${esc(npc.description)}</p>
|
||||
<div class="bpn-npc-card__sig"><span class="bpn-label">Why they matter</span>${esc(npc.significance)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function otemRow(o) {
|
||||
return `<div class="bpn-detail-item">
|
||||
<div class="bpn-detail-item__name">${esc(o.name)}</div>
|
||||
<div class="bpn-detail-item__line">${esc(o.player)}</div>
|
||||
/** A simple item card: line-art icon, what it is, a description. */
|
||||
export function itemCardHTML(item, { artMode = "inline", accent = "#9fb0c4" } = {}) {
|
||||
const art = artMode === "img"
|
||||
? `<img src="${itemIconDataURI(item, { size: 120, accent })}" alt="${esc(item.name)}">`
|
||||
: itemIconSVG(item, { size: 120, accent });
|
||||
return `<div class="bpn-item-card">
|
||||
<div class="bpn-item-card__art">${art}</div>
|
||||
<div class="bpn-item-card__body">
|
||||
<div class="bpn-item-card__name">${esc(item.name)}</div>
|
||||
<p class="bpn-item-card__desc">${esc(item.description)}</p>
|
||||
<div class="bpn-item-card__note">${esc(item.player)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -163,11 +179,11 @@ export function fieldDossierHTML(bpn, { artMode = "inline" } = {}) {
|
||||
<div class="bpn-body">
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Cast on the Ground</h2>
|
||||
<div class="bpn-detail-list">${bpn.cast.map(castRow).join("")}</div>
|
||||
<div class="bpn-card-grid">${bpn.cast.map((n) => npcCardHTML(n, { artMode, accent: c.accent })).join("")}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="bpn-section__title">OTEM · Items & Equipment</h2>
|
||||
<div class="bpn-detail-list">${bpn.otem.map(otemRow).join("")}</div>
|
||||
<div class="bpn-card-grid bpn-card-grid--items">${bpn.otem.map((o) => itemCardHTML(o, { artMode, accent: c.accent })).join("")}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Key Locations</h2>
|
||||
|
||||
@@ -350,6 +350,54 @@
|
||||
.bpn-sitemap { margin-bottom: 12px; }
|
||||
.bpn-sitemap svg, .bpn-sitemap__img { width: 100%; height: auto; display: block; border-radius: 10px; }
|
||||
|
||||
/* NPC + item cards */
|
||||
.bpn-card-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
.bpn-card-grid--items { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||
|
||||
.bpn-npc-card, .bpn-item-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
|
||||
border: 1px solid var(--bpn-line);
|
||||
}
|
||||
.bpn-npc-card { grid-template-columns: 96px 1fr; }
|
||||
.bpn-item-card { grid-template-columns: 72px 1fr; align-items: start; }
|
||||
|
||||
.bpn-npc-card__art { width: 96px; }
|
||||
.bpn-item-card__art { width: 72px; }
|
||||
.bpn-npc-card__art svg, .bpn-npc-card__art img,
|
||||
.bpn-item-card__art svg, .bpn-item-card__art img { width: 100%; height: auto; display: block; border-radius: 8px; }
|
||||
|
||||
.bpn-npc-card__name, .bpn-item-card__name {
|
||||
font-family: var(--bpn-head);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 24%, var(--bpn-text));
|
||||
}
|
||||
.bpn-npc-card__role {
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bpn-muted);
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.bpn-npc-card__desc, .bpn-item-card__desc { margin: 0 0 8px; font-size: 0.98rem; line-height: 1.5; color: var(--bpn-text); }
|
||||
.bpn-npc-card__sig .bpn-label { margin-bottom: 2px; }
|
||||
.bpn-npc-card__sig { font-size: 0.95rem; color: var(--bpn-text); }
|
||||
.bpn-item-card__note { font-family: var(--bpn-mono); font-size: 0.78rem; color: var(--bpn-muted); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bpn-npc-card { grid-template-columns: 72px 1fr; }
|
||||
.bpn-npc-card__art { width: 72px; }
|
||||
}
|
||||
|
||||
/* ===================== GM DOSSIER ===================== */
|
||||
|
||||
.bpn-dossier .bpn-doc { box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); }
|
||||
|
||||
Reference in New Issue
Block a user