diff --git a/README.md b/README.md index 72cd293..55169e4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/module.json b/module.json index d900d16..9e1e6a4 100644 --- a/module.json +++ b/module.json @@ -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.0", "compatibility": { "minimum": "13", "verified": "14.361", diff --git a/module/app/generator-app.mjs b/module/app/generator-app.mjs index 1aa97d1..cc6963b 100644 --- a/module/app/generator-app.mjs +++ b/module/app/generator-app.mjs @@ -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) => `
${html}
`; 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)) + `

Player document: @UUID[${playerEntry.uuid}]{${base} [Player]}

`, O.NONE) - ] + await JournalEntry.create({ + name: `${base} [GM]`, folder: fid, ownership: { default: O.NONE }, flags, + pages: [page("GM Dossier", wrap(dossierHTML(bpn, ART)) + `

Player document: @UUID[${playerEntry.uuid}]{${base} [Player]}

`, 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() { diff --git a/module/vendor/art/bpn-art.js b/module/vendor/art/bpn-art.js index 6cb496c..284d58f 100644 --- a/module/vendor/art/bpn-art.js +++ b/module/vendor/art/bpn-art.js @@ -331,6 +331,113 @@ export function portraitSVG(bpn, { size = 120 } = {}) { `; } +/* ------------------------------------------------------------------------ */ +/* 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 = ``; + const jaw = ``; + + let hair = ""; + if (top === "hairShort") hair = ``; + else if (top === "hairCrop") hair = ``; + else if (top === "bald") hair = ``; + else if (top === "hood") hair = ``; + else if (top === "helmet") hair = ``; + else if (top === "cap") hair = ``; + + const ey = cy - 4; + let eyeMarks = ""; + if (eyes === "plain") eyeMarks = ``; + else if (eyes === "visor") eyeMarks = ``; + else if (eyes === "goggles") eyeMarks = ``; + else eyeMarks = ``; + + const nose = ``; + + const my = cy + 17; + let mouthMark = ""; + if (mouth === "neutral") mouthMark = ``; + else if (mouth === "grim") mouthMark = ``; + else if (mouth === "smirk") mouthMark = ``; + else mouthMark = ``; + + let extraMark = ""; + if (extra === "earpiece") extraMark = ``; + else if (extra === "scar") extraMark = ``; + else if (extra === "collar") extraMark = ``; + + // light hatch shading down the right cheek + const hatch = Array.from({ length: 4 }, (_, i) => + ``).join(""); + + const shoulders = ``; + + return ` + + + IDENT + ${shoulders} + ${extra === "collar" ? extraMark : ""} + ${head}${jaw}${hatch} + ${hair} + ${eyeMarks}${nose}${mouthMark} + ${extra !== "collar" ? extraMark : ""} + +`; +} + +/* ------------------------------------------------------------------------ */ +/* 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: ``, + canister: ``, + weapon: ``, + chip: ``, + crate: ``, + bug: ``, + component: `${Array.from({ length: 8 }, (_, i) => { const a = i / 8 * Math.PI * 2; return ``; }).join("")}`, + relay: ``, + kit: ``, + drone: ``, + key: ``, + card: `` + }; + const inner = glyphs[cat] ?? glyphs.slate; + return ` + + ${inner} +`; +} + /* ------------------------------------------------------------------------ */ /* 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))}`; +} diff --git a/module/vendor/core/bpn-core.js b/module/vendor/core/bpn-core.js index 9c2ad02..71d9b2a 100644 --- a/module/vendor/core/bpn-core.js +++ b/module/vendor/core/bpn-core.js @@ -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, diff --git a/module/vendor/core/data/detail.js b/module/vendor/core/data/detail.js index bba8a93..2e564ad 100644 --- a/module/vendor/core/data/detail.js +++ b/module/vendor/core/data/detail.js @@ -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: [ diff --git a/module/vendor/render/bpn-render.js b/module/vendor/render/bpn-render.js index 532c74a..2ee11cb 100644 --- a/module/vendor/render/bpn-render.js +++ b/module/vendor/render/bpn-render.js @@ -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 `
-
${esc(n.name)}
-
${esc(n.role)}
-
${esc(n.player)}
+/** 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" + ? `${esc(npc.name)}` + : npcPortraitSVG(npc, { size: 168, accent }); + return `
+
${art}
+
+
${esc(npc.name)}
+
${esc(npc.role)} · ${esc(npc.disposition)}
+

${esc(npc.description)}

+
Why they matter${esc(npc.significance)}
+
`; } -function otemRow(o) { - return `
-
${esc(o.name)}
-
${esc(o.player)}
+/** A simple item card: line-art icon, what it is, a description. */ +export function itemCardHTML(item, { artMode = "inline", accent = "#9fb0c4" } = {}) { + const art = artMode === "img" + ? `${esc(item.name)}` + : itemIconSVG(item, { size: 120, accent }); + return `
+
${art}
+
+
${esc(item.name)}
+

${esc(item.description)}

+
${esc(item.player)}
+
`; } @@ -163,11 +179,11 @@ export function fieldDossierHTML(bpn, { artMode = "inline" } = {}) {

Cast on the Ground

-
${bpn.cast.map(castRow).join("")}
+
${bpn.cast.map((n) => npcCardHTML(n, { artMode, accent: c.accent })).join("")}

OTEM · Items & Equipment

-
${bpn.otem.map(otemRow).join("")}
+
${bpn.otem.map((o) => itemCardHTML(o, { artMode, accent: c.accent })).join("")}

Key Locations

diff --git a/styles/bpn-render.css b/styles/bpn-render.css index a65553a..e98e217 100644 --- a/styles/bpn-render.css +++ b/styles/bpn-render.css @@ -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); }