/* Blueprint News — procedural SVG artwork. * * Flat, graphic, stencil/propaganda-poster styling: hard angles, heavy blacks, * a single colour-band accent, halftone and scanline textures. Deliberately * NOT photorealistic. All shapes derive from the BPN seed, so a given briefing * always renders the same art while every briefing differs. * * Each colour band gets its own SCENE — a foreground motif (sewer arch, transit * concourse, perimeter wall, vault corridor, …) plus tuned atmosphere and * lighting — so a Blue street job and a Black covert op never look alike. * * No DOM and no engine APIs — each function returns an SVG markup string. */ /* --- seeded rng (local copy so art is usable standalone) ----------------- */ function hashSeed(str) { let h = 2166136261 >>> 0; const s = String(str); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function mulberry32(a) { return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function rngFor(seed, salt) { return mulberry32(hashSeed(`${seed}::${salt}`)); } const rint = (rng, lo, hi) => Math.floor(rng() * (hi - lo + 1)) + lo; const rfloat = (rng, lo, hi) => rng() * (hi - lo) + lo; const r2 = (n) => Math.round(n * 100) / 100; /* --- colour helpers ------------------------------------------------------ */ function hexToRgb(hex) { const h = String(hex || "#dce8f9").replace("#", ""); const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h; return { r: parseInt(n.slice(0, 2), 16), g: parseInt(n.slice(2, 4), 16), b: parseInt(n.slice(4, 6), 16) }; } function mix(hex, withHex, t) { const a = hexToRgb(hex); const b = hexToRgb(withHex); const c = (k) => Math.round(a[k] + (b[k] - a[k]) * t); return `rgb(${c("r")},${c("g")},${c("b")})`; } function rgba(hex, alpha) { const { r, g, b } = hexToRgb(hex); return `rgba(${r},${g},${b},${alpha})`; } /* --- per-band scene definitions ----------------------------------------- */ /* motif: which foreground silhouette to draw. rain/smog/crowd: atmospheric layer. floodlights/beacons: lighting. density: building/back-skyline busyness (0 sparse … 1 dense). */ const SCENES = { blue: { motif: "tunnel", rain: 0.8, smog: 0, crowd: 0, floodlights: false, beacons: false, density: 0.5 }, yellow: { motif: "transit", rain: 0, smog: 0.2, crowd: 0.9, floodlights: false, beacons: false, density: 0.7 }, green: { motif: "wall", rain: 0.5, smog: 0.1, crowd: 0, floodlights: true, beacons: false, density: 0.5 }, white: { motif: "cordon", rain: 0.2, smog: 0.1, crowd: 0, floodlights: false, beacons: false, density: 0.6 }, grey: { motif: "gantry", rain: 0, smog: 0.6, crowd: 0, floodlights: false, beacons: false, density: 0.8 }, silver: { motif: "stage", rain: 0, smog: 0, crowd: 0.7, floodlights: true, beacons: false, density: 0.6 }, jade: { motif: "canal", rain: 0.3, smog: 0.8, crowd: 0, floodlights: false, beacons: false, density: 0.5 }, red: { motif: "tower", rain: 0.6, smog: 0.3, crowd: 0, floodlights: true, beacons: true, density: 0.7 }, black: { motif: "vault", rain: 0, smog: 0, crowd: 0, floodlights: false, beacons: false, density: 0.2 }, platinum: { motif: "exec", rain: 0, smog: 0.1, crowd: 0, floodlights: false, beacons: false, density: 0.3 } }; /* Mission-focus glyph drawn inside the crest pupil. */ function focusGlyph(focus = "", accent = "#fff") { const f = String(focus).toLowerCase(); const stroke = `stroke="${accent}" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round"`; if (f.includes("eliminate") || f.includes("hunt") || f.includes("kill")) return ``; if (f.includes("escort") || f.includes("protect")) return ``; if (f.includes("retrieve")) return ``; if (f.includes("investigate") || f.includes("profile")) return ``; if (f.includes("contain") || f.includes("quarantine")) return ``; if (f.includes("riot") || f.includes("crowd")) return ``; return ``; } /* ------------------------------------------------------------------------ */ /* CREST — the BPN sigil. */ /* ------------------------------------------------------------------------ */ export function crestSVG(bpn, { size = 128 } = {}) { const accent = bpn?.colour?.accent ?? "#dce8f9"; const seed = bpn?.seed ?? "BPN"; const uid = String(seed).replace(/[^A-Za-z0-9]/g, ""); const rng = rngFor(seed, "crest"); const dark = "#101722"; const rim = mix(accent, "#ffffff", 0.12); const glyph = focusGlyph(bpn?.missionFocus ?? "", mix(accent, "#ffffff", 0.25)); const rivet = (x, y) => ``; const ticks = Array.from({ length: 12 }, (_, i) => { const a = (i / 12) * Math.PI * 2; const x1 = 64 + Math.cos(a) * 50, y1 = 64 + Math.sin(a) * 50; const x2 = 64 + Math.cos(a) * (i % 3 === 0 ? 44 : 47), y2 = 64 + Math.sin(a) * (i % 3 === 0 ? 44 : 47); return ``; }).join(""); const jitter = r2(rfloat(rng, -2, 2)); return ` ${ticks} ${rivet(20, 20)}${rivet(108, 20)}${rivet(20, 108)}${rivet(108, 108)} ${glyph} `; } /* ------------------------------------------------------------------------ */ /* FOREGROUND MOTIFS — one per scene. Each returns markup positioned within */ /* a 1200x360 banner space (W,H passed in). */ /* ------------------------------------------------------------------------ */ function litWindowGrid(rng, x, y, w, h, accent, alpha, step = 14) { const out = []; for (let wx = x + 6; wx < x + w - 6; wx += step) { for (let wy = y + 8; wy < y + h - 6; wy += 18) { if (rng() < 0.32) out.push(``); } } return out.join(""); } const MOTIFS = { // generic angular skyline used by most street-level scenes skyline(rng, W, H, accent, density) { let x = -20; const out = []; const gap = density > 0.6 ? [2, 8] : [6, 16]; while (x < W + 20) { const w = rint(rng, 34, 96), h = rint(rng, H * 0.4, H * 0.82), top = H + 4 - h; out.push(``); if (rng() < 0.4) { const mx = x + rint(rng, 6, w - 6); out.push(``); } out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.85)); x += w + rint(rng, gap[0], gap[1]); } return out.join(""); }, tunnel(rng, W, H, accent) { const cx = W * rfloat(rng, 0.4, 0.6), out = []; for (let i = 0; i < 5; i++) { const ry = H * (0.34 + i * 0.11), rx = ry * 1.5; out.push(``); } out.push(``); for (let i = 0; i < 26; i++) { const x = rfloat(rng, 0, W); out.push(``); } // water glints out.push(``); // side pipes return out.join(""); }, transit(rng, W, H, accent) { const out = [MOTIFS.skyline(rng, W, H, accent, 0.7)]; for (let i = 0; i < 6; i++) { const x = (i + 0.5) * (W / 6); out.push(``); if (rng() < 0.8) out.push(``); } // pillars + route boards return out.join(""); }, wall(rng, W, H, accent) { const out = [], top = H * 0.34; out.push(``); for (let x = 0; x < W; x += 64) out.push(``); // plating for (let x = 0; x < W; x += 64) out.push(``); // battlements return out.join(""); }, cordon(rng, W, H, accent) { const out = [MOTIFS.skyline(rng, W, H, accent, 0.6)]; // diagonal cordon tape across the lower third for (let k = -1; k <= 1; k += 2) { const y = H * (0.72 + k * 0.06); out.push(``); } return out.join(""); }, gantry(rng, W, H, accent) { const out = [MOTIFS.skyline(rng, W, H, accent, 0.8)]; for (let i = 0; i < 10; i++) { const x = rfloat(rng, 0, W - 70), y = H - rint(rng, 24, 70); out.push(``); } // containers out.push(``); // gantry crane return out.join(""); }, stage(rng, W, H, accent) { const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)]; out.push(``); // truss for (let i = 0; i < 5; i++) { const x = W * (0.28 + i * 0.11); out.push(``); } // spotlight cones return out.join(""); }, canal(rng, W, H, accent) { const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)]; out.push(``); // canal for (let i = 0; i < 7; i++) { const x = rfloat(rng, 30, W - 30); out.push(``); } // hazard barrels return out.join(""); }, tower(rng, W, H, accent) { const out = [MOTIFS.skyline(rng, W, H, accent, 0.7)]; const tx = W * rfloat(rng, 0.4, 0.6); out.push(``); // broadcast tower out.push(``); out.push(``); // monorail line return out.join(""); }, vault(rng, W, H, accent) { const cx = W * 0.5, out = []; for (let i = 0; i < 6; i++) { const s = 1 - i * 0.15, w = W * 0.5 * s, h = H * 0.9 * s; out.push(``); } // receding corridor out.push(``); // far door for (let i = 0; i < 5; i++) out.push(``); // biometric panel return out.join(""); }, exec(rng, W, H, accent) { const out = []; let x = 40; while (x < W - 40) { const w = rint(rng, 70, 120), h = rint(rng, H * 0.55, H * 0.95), top = H + 4 - h; out.push(``); out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.4, 18)); x += w + rint(rng, 40, 90); } out.push(``); return out.join(""); } }; /* ------------------------------------------------------------------------ */ /* BANNER — the wide hero backdrop, composed from the band's scene. */ /* ------------------------------------------------------------------------ */ export function bannerSVG(bpn, { width = 1200, height = 360 } = {}) { const accent = bpn?.colour?.accent ?? "#dce8f9"; const key = bpn?.colourKey ?? "white"; const seed = bpn?.seed ?? "BPN"; const uid = String(seed).replace(/[^A-Za-z0-9]/g, ""); const scene = SCENES[key] ?? SCENES.white; const rng = rngFor(seed, "banner"); const dark = "#0b1018"; const W = width, H = height; // hazy back skyline for depth (skip for vault/exec which set their own mood) let back = ""; if (!["vault", "exec"].includes(scene.motif)) { let x = -20; const out = []; while (x < W + 20) { const w = rint(rng, 40, 96), h = rint(rng, H * 0.28, H * 0.6), top = H * 0.92 - h; out.push(``); x += w + rint(rng, 4, 14); } back = out.join(""); } const fg = (MOTIFS[scene.motif] ?? MOTIFS.skyline)(rng, W, H, accent, scene.density); // searchlight beams const beams = Array.from({ length: rint(rng, 2, 4) }, () => { const ox = rfloat(rng, 0, W), s = rfloat(rng, 40, 120); return ``; }).join(""); // atmosphere layers let rain = "", smog = "", crowd = ""; if (scene.rain > 0) rain = Array.from({ length: Math.round(scene.rain * 110) }, () => { const x = rfloat(rng, 0, W), y = rfloat(rng, 0, H), len = rfloat(rng, 3, 11); return ``; }).join(""); if (scene.smog > 0) smog = Array.from({ length: Math.round(scene.smog * 5) }, (_, i) => ``).join(""); if (scene.crowd > 0) crowd = Array.from({ length: Math.round(scene.crowd * 90) }, () => { const x = rfloat(rng, 0, W), y = rfloat(rng, H * 0.82, H - 4); return ``; }).join(""); // lighting let floods = "", beaconGlow = ""; if (scene.floodlights) floods = Array.from({ length: rint(rng, 2, 3) }, () => { const x = rfloat(rng, W * 0.2, W * 0.8); return ``; }).join(""); if (scene.beacons) beaconGlow = Array.from({ length: rint(rng, 2, 4) }, () => ``).join(""); // hazard chevrons for high-alert bands let hazard = ""; if (["red", "black", "jade", "green"].includes(key)) { const bandY = H - 16, chev = []; for (let x = -20; x < W + 20; x += 28) chev.push(``); hazard = `${chev.join("")}`; } // giant faint Third Eye const eyeX = rng() < 0.5 ? W * 0.78 : W * 0.22, eyeScale = rfloat(rng, 1.6, 2.2); const eye = ``; return ` ${beams}${beaconGlow}${smog} ${eye} ${back} ${fg} ${floods}${crowd}${rain} ${hazard} `; } /* ------------------------------------------------------------------------ */ /* PORTRAIT — a stylised halftone contact silhouette for the brief. */ /* Flat shoulders + head bust, halftone dot shading, accent rim. Not a face: */ /* an anonymous corporate ID-photo abstraction, deliberately non-realistic. */ /* ------------------------------------------------------------------------ */ export function portraitSVG(bpn, { size = 120 } = {}) { const accent = bpn?.colour?.accent ?? "#dce8f9"; const seed = bpn?.seed ?? "BPN"; const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "p"; const rng = rngFor(seed, "portrait"); const dark = "#0c1119"; // halftone dots over the bust area const dots = []; for (let y = 18; y < 120; y += 8) { for (let x = 12; x < 108; x += 8) { const d = Math.hypot(x - 60, y - 70); const p = Math.max(0, 1 - d / 70); if (rng() < p * 0.7) dots.push(``); } } return ` ${dots.join("")} `; } /* ------------------------------------------------------------------------ */ /* 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 */ /* faint grid, with an entry marker. Labels live in the dossier list. */ /* ------------------------------------------------------------------------ */ export function siteMapSVG(bpn, { width = 640, height = 320 } = {}) { const accent = bpn?.colour?.accent ?? "#dce8f9"; const seed = bpn?.seed ?? "BPN"; const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "m"; const rng = rngFor(seed, "map"); const dark = "#0b1018"; const sites = Array.isArray(bpn?.sites) && bpn.sites.length ? bpn.sites : [{}, {}, {}]; const n = sites.length; const W = width, H = height; const mx = 70, my = 64; // node positions: spread along x, seeded y within the band const nodes = sites.map((_, i) => ({ x: mx + (n === 1 ? (W - 2 * mx) / 2 : (i * (W - 2 * mx)) / (n - 1)), y: my + rfloat(rng, 0, H - 2 * my) })); // route polyline through the nodes const route = nodes.map((p, i) => `${i ? "L" : "M"}${r2(p.x)} ${r2(p.y)}`).join(" "); const nodeMarks = nodes.map((p, i) => ` ${i + 1} `).join(""); // entry chevron at the first node const e = nodes[0]; const entry = `ENTRY`; return ` SECTOR SCHEMATIC // ${String(bpn?.bpnId ?? "")} ${entry} ${nodeMarks} `; } /* Convenience: data URIs (handy as srcs or journal icons, and required in hosts that sanitise inline such as Foundry journal pages). */ export function crestDataURI(bpn, opts) { return `data:image/svg+xml;utf8,${encodeURIComponent(crestSVG(bpn, opts))}`; } export function bannerDataURI(bpn, opts) { return `data:image/svg+xml;utf8,${encodeURIComponent(bannerSVG(bpn, opts))}`; } export function portraitDataURI(bpn, opts) { return `data:image/svg+xml;utf8,${encodeURIComponent(portraitSVG(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))}`; }