Files
mapwright/module/lib/render-vector.mjs
T
slaguru666andClaude Opus 4.8 100e514c4d Mapwright v0.5.0 — procedural battle map generator for Foundry VTT
Buildings (modern/fantasy, multi-floor, footprint shapes), caves, outdoor
biomes, and town/village + city-block settlements. Auto-places Foundry walls,
doors, windows, lighting; furniture as locked tiles; live preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:05:25 +01:00

355 lines
16 KiB
JavaScript

/**
* Procedural-vector renderer: layout + segments -> SVG string.
*
* No external art. The "good look" comes from established cartography tricks:
* - thick walls with a soft cast shadow onto the floor (reads as 3-D)
* - Dyson-style hatching just outside the exterior walls
* - per-theme floor texture drawn in SVG (tiles / flagstones / boards)
* - architectural door symbols (leaf + swing arc)
*
* Pure + deterministic: same seed -> same decoration. Runs in Node and browser.
*/
import { Rng } from "./rng.mjs";
import { pieceInnerSVG } from "./furniture.mjs";
const THEMES = {
modern: {
outside: "#23262b", floorBase: "#d9dde2", floorSeam: "#b3bbc4",
grid: "#9aa3ad", wall: "#2b2f36", wallTop: "#3c424b",
doorFrame: "#3a3f45", doorLeaf: "#1f2329", doorFill: "#5a6470",
window: "#8fc4e2", windowFrame: "#3a3f45",
label: "#2b2f36", hatch: "#3a3f45", floorStyle: "tile"
},
fantasy: {
outside: "#2a241c", floorBase: "#bcab8f", floorSeam: "#8d7a5c",
grid: "#6f5f47", wall: "#372d20", wallTop: "#4c3f2c",
doorFrame: "#6a4a2c", doorLeaf: "#3a2614", doorFill: "#7a5532",
window: "#86b8c4", windowFrame: "#4a3a26",
label: "#322617", hatch: "#4a3a26", floorStyle: "flagstone"
}
};
const PX = (v, gs) => +(v * gs).toFixed(1);
/**
* @param {object} args
* @param {object} args.layout generateBuilding() output
* @param {object} args.walls buildWalls() output ({segments,...})
* @param {number} [args.gridSize]
* @param {string} [args.subtype] "modern" | "fantasy"
* @param {boolean} [args.showGrid]
* @param {boolean} [args.showLabels]
* @param {string|number} [args.seed]
* @returns {string} SVG markup
*/
export function renderSVG(args) {
const { layout } = args;
const gs = args.gridSize ?? 70;
const theme = THEMES[args.subtype] ? THEMES[args.subtype] : THEMES.modern;
const showGrid = args.showGrid !== false;
const showLabels = args.showLabels !== false;
const rng = new Rng(`${args.seed ?? "deco"}::render`);
const W = layout.cols * gs;
const H = layout.rows * gs;
const subtype = THEMES[args.subtype] ? args.subtype : "modern";
const region = layout.region;
const cols = layout.cols, rows = layout.rows;
const showFurniture = args.showFurniture !== false;
const segs = args.walls.segments;
const out = [];
const floorPath = buildFloorPath(region, cols, rows, gs);
out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`);
// --- defs: shadow filter + clip to floor ---------------------------------
out.push(`<defs>`);
out.push(
`<filter id="mw-wallshadow" x="-20%" y="-20%" width="140%" height="140%">` +
`<feDropShadow dx="0" dy="0" stdDeviation="${(gs * 0.06).toFixed(1)}" flood-color="#000000" flood-opacity="0.45"/>` +
`</filter>`
);
out.push(`<clipPath id="mw-floorclip"><path d="${floorPath}"/></clipPath>`);
out.push(`</defs>`);
// --- outside ground ------------------------------------------------------
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${theme.outside}"/>`);
// --- exterior hatching (drawn under the floor so it tucks beneath walls) --
out.push(`<g stroke="${theme.hatch}" stroke-width="${(gs * 0.03).toFixed(1)}" stroke-linecap="round" opacity="0.5">`);
out.push(exteriorHatching(segs, region, cols, rows, gs, rng));
out.push(`</g>`);
// --- floor base + texture + furniture (clipped to footprint) -------------
out.push(`<g clip-path="url(#mw-floorclip)">`);
out.push(`<path d="${floorPath}" fill="${theme.floorBase}"/>`);
out.push(floorTexture(theme, layout, gs, rng));
if (showGrid) out.push(gridOverlay(layout, gs, theme.grid));
if (showFurniture && args.placements?.length) out.push(drawPlacements(args.placements, subtype));
out.push(`</g>`);
// --- walls (with cast shadow) --------------------------------------------
const wallW = (gs * 0.17).toFixed(1);
out.push(`<g filter="url(#mw-wallshadow)">`);
out.push(`<g stroke="${theme.wall}" stroke-width="${wallW}" stroke-linecap="butt" fill="none">`);
for (const s of segs) {
if (s.kind !== "wall") continue;
out.push(`<line x1="${PX(s.x1, gs)}" y1="${PX(s.y1, gs)}" x2="${PX(s.x2, gs)}" y2="${PX(s.y2, gs)}"/>`);
}
out.push(`</g></g>`);
// --- doors + windows (era-aware artwork) ---------------------------------
for (const s of segs) {
if (s.kind === "door") out.push(doorSymbol(s, gs, theme, subtype));
else if (s.kind === "window") out.push(windowSymbol(s, gs, theme, gs * 0.17, subtype));
}
// --- room labels ---------------------------------------------------------
if (showLabels) {
out.push(`<g font-family="'Segoe UI', 'Helvetica Neue', sans-serif" text-anchor="middle">`);
for (const room of layout.rooms) {
if (!room.label) continue;
const fontSize = Math.max(8, Math.min(gs * 0.22, room.w * gs * 0.3));
const ly = PX(room.cy, gs) - (room.h * gs) * 0.3;
out.push(
`<text x="${PX(room.cx, gs)}" y="${ly.toFixed(1)}" fill="${theme.label}" stroke="${theme.floorBase}" stroke-width="${(fontSize * 0.18).toFixed(1)}" paint-order="stroke" ` +
`font-size="${fontSize.toFixed(1)}" font-weight="700" opacity="0.85">${escapeXml(room.label.toUpperCase())}</text>`
);
}
out.push(`</g>`);
}
out.push(`</svg>`);
return out.join("");
}
/** Draw furniture placements (same geometry the locked Tiles use). */
function drawPlacements(placements, subtype) {
let out = `<g stroke-linejoin="round">`;
for (const p of placements) {
const inner = pieceInnerSVG(p.kind, subtype, p.wpx, p.hpx);
const tx = (p.cx - p.wpx / 2).toFixed(1);
const ty = (p.cy - p.hpx / 2).toFixed(1);
out += `<g transform="translate(${tx},${ty}) rotate(${p.rotation}, ${(p.wpx / 2).toFixed(1)}, ${(p.hpx / 2).toFixed(1)})">${inner}</g>`;
}
return out + `</g>`;
}
/** Floor path from the region grid: per-row runs of in-room cells -> rects. */
function buildFloorPath(region, cols, rows, gs) {
let d = "";
for (let y = 0; y < rows; y += 1) {
let x = 0;
while (x < cols) {
if (region[y][x] < 0) { x += 1; continue; }
let run = 0;
while (x + run < cols && region[y][x + run] >= 0) run += 1;
d += `M${PX(x, gs)},${PX(y, gs)}h${PX(run, gs)}v${PX(1, gs)}h${PX(-run, gs)}Z`;
x += run;
}
}
return d;
}
function regAt(region, x, y, cols, rows) {
if (x < 0 || y < 0 || x >= cols || y >= rows) return -1;
return region[y][x];
}
// --- floor textures --------------------------------------------------------
function floorTexture(theme, layout, gs, rng) {
if (theme.floorStyle === "flagstone") return flagstoneTexture(theme, layout, gs, rng);
return tileTexture(theme, layout, gs);
}
function tileTexture(theme, layout, gs) {
// Clean square-tile seams across the footprint.
const fp = layout.footprint;
const lines = [];
lines.push(`<g stroke="${theme.floorSeam}" stroke-width="${(gs * 0.015).toFixed(1)}" opacity="0.6">`);
for (let x = fp.x; x <= fp.x + fp.w; x += 1) {
lines.push(`<line x1="${PX(x, gs)}" y1="${PX(fp.y, gs)}" x2="${PX(x, gs)}" y2="${PX(fp.y + fp.h, gs)}"/>`);
}
for (let y = fp.y; y <= fp.y + fp.h; y += 1) {
lines.push(`<line x1="${PX(fp.x, gs)}" y1="${PX(y, gs)}" x2="${PX(fp.x + fp.w, gs)}" y2="${PX(y, gs)}"/>`);
}
lines.push(`</g>`);
return lines.join("");
}
function flagstoneTexture(theme, layout, gs, rng) {
// Irregular flagstones: per in-room cell, a jittered quad. (Clipped to floor.)
const { region, cols, rows } = layout;
const j = gs * 0.14;
const out = [`<g stroke="${theme.floorSeam}" stroke-width="${(gs * 0.022).toFixed(1)}" fill="none" opacity="0.55" stroke-linejoin="round">`];
for (let y = 0; y < rows; y += 1) {
for (let x = 0; x < cols; x += 1) {
if (region[y][x] < 0) continue;
const p = (cx, cy) => `${PX(cx, gs) + (rng.float() - 0.5) * j},${PX(cy, gs) + (rng.float() - 0.5) * j}`;
out.push(`<path d="M${p(x, y)} L${p(x + 1, y)} L${p(x + 1, y + 1)} L${p(x, y + 1)} Z"/>`);
}
}
out.push(`</g>`);
return out.join("");
}
function gridOverlay(layout, gs, color) {
const fp = layout.footprint;
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.01).toFixed(1)}" opacity="0.22">`];
for (let x = fp.x; x <= fp.x + fp.w; x += 1) {
out.push(`<line x1="${PX(x, gs)}" y1="${PX(fp.y, gs)}" x2="${PX(x, gs)}" y2="${PX(fp.y + fp.h, gs)}"/>`);
}
for (let y = fp.y; y <= fp.y + fp.h; y += 1) {
out.push(`<line x1="${PX(fp.x, gs)}" y1="${PX(y, gs)}" x2="${PX(fp.x + fp.w, gs)}" y2="${PX(y, gs)}"/>`);
}
out.push(`</g>`);
return out.join("");
}
// --- exterior hatching -----------------------------------------------------
function exteriorHatching(segs, region, cols, rows, gs, rng) {
const out = [];
const step = 0.4; // along-wall spacing in cells
const len = gs * 0.34; // tick length in px
for (const s of segs) {
if (s.kind !== "wall") continue;
const isV = s.x1 === s.x2;
// Detect exterior + outward direction by sampling either side at the midpoint.
let nx = 0, ny = 0, exterior = false;
if (isV) {
const X = s.x1, midY = Math.floor((s.y1 + s.y2) / 2);
const left = regAt(region, X - 1, midY, cols, rows);
const right = regAt(region, X, midY, cols, rows);
if (left === -1 && right >= 0) { nx = -1; exterior = true; }
else if (right === -1 && left >= 0) { nx = 1; exterior = true; }
} else {
const Y = s.y1, midX = Math.floor((s.x1 + s.x2) / 2);
const top = regAt(region, midX, Y - 1, cols, rows);
const bottom = regAt(region, midX, Y, cols, rows);
if (top === -1 && bottom >= 0) { ny = -1; exterior = true; }
else if (bottom === -1 && top >= 0) { ny = 1; exterior = true; }
}
if (!exterior) continue;
const length = isV ? (s.y2 - s.y1) : (s.x2 - s.x1);
for (let t = step / 2; t < length; t += step) {
const bx = isV ? s.x1 : s.x1 + t;
const by = isV ? s.y1 + t : s.y1;
const px = PX(bx, gs), py = PX(by, gs);
// slight slant for a hand-drawn feel
const slant = (rng.float() - 0.5) * 0.5;
const ex = px + nx * len + (isV ? 0 : slant * len);
const ey = py + ny * len + (isV ? slant * len : 0);
out.push(`<line x1="${px.toFixed(1)}" y1="${py.toFixed(1)}" x2="${ex.toFixed(1)}" y2="${ey.toFixed(1)}"/>`);
}
}
return out.join("");
}
// --- door symbol (era-aware artwork) ---------------------------------------
function doorSymbol(s, gs, theme, subtype) {
const x1 = PX(s.x1, gs), y1 = PX(s.y1, gs);
const x2 = PX(s.x2, gs), y2 = PX(s.y2, gs);
const L = Math.hypot(x2 - x1, y2 - y1) || 1;
const ux = (x2 - x1) / L, uy = (y2 - y1) / L; // along the wall
const ppx = -uy, ppy = ux; // wall perpendicular
const isV = s.orient === "v";
const insideA = s.inside === "a";
let nx = 0, ny = 0;
if (isV) nx = insideA ? -1 : 1; else ny = insideA ? -1 : 1;
const wallTh = gs * 0.17;
const out = [`<g>`];
// Jamb posts (door frame) straddling the wall at both ends.
const jamb = wallTh * 1.25;
for (const [ex, ey] of [[x1, y1], [x2, y2]]) {
out.push(
`<line x1="${(ex - ppx * jamb / 2).toFixed(1)}" y1="${(ey - ppy * jamb / 2).toFixed(1)}" ` +
`x2="${(ex + ppx * jamb / 2).toFixed(1)}" y2="${(ey + ppy * jamb / 2).toFixed(1)}" ` +
`stroke="${theme.doorFrame}" stroke-width="${(gs * 0.075).toFixed(1)}" stroke-linecap="round"/>`
);
}
// Hinge at end 1; leaf swings ~75° toward the inside.
const hx = x1, hy = y1;
const ang = (75 * Math.PI) / 180;
const sign = (ux * ny - uy * nx) >= 0 ? 1 : -1;
const cos = Math.cos(ang * sign), sin = Math.sin(ang * sign);
const leafLen = L * 0.94;
const dx = ux * cos - uy * sin, dy = ux * sin + uy * cos; // leaf direction
const lx = hx + dx * leafLen, ly = hy + dy * leafLen;
const ox = hx + ux * leafLen, oy = hy + uy * leafLen; // closed position
// Swing arc.
out.push(
`<path d="M${ox.toFixed(1)},${oy.toFixed(1)} A${leafLen.toFixed(1)},${leafLen.toFixed(1)} 0 0 ${sign > 0 ? 1 : 0} ${lx.toFixed(1)},${ly.toFixed(1)}" ` +
`fill="none" stroke="${theme.doorLeaf}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.35" ` +
`stroke-dasharray="${(gs * 0.05).toFixed(1)} ${(gs * 0.05).toFixed(1)}"/>`
);
// Leaf as a filled panel (planked wood / metal slab).
const pdx = -dy, pdy = dx; // across the leaf thickness
const th = gs * 0.13;
const pt = (bx, by, sx, sy) => `${(bx + pdx * sx + dx * sy).toFixed(1)},${(by + pdy * sx + dy * sy).toFixed(1)}`;
out.push(
`<path d="M${pt(hx, hy, th / 2, 0)} L${pt(hx, hy, th / 2, leafLen)} L${pt(hx, hy, -th / 2, leafLen)} L${pt(hx, hy, -th / 2, 0)} Z" ` +
`fill="${theme.doorFill}" stroke="${theme.doorLeaf}" stroke-width="${(gs * 0.018).toFixed(1)}"/>`
);
if (subtype === "fantasy") {
for (const f of [0.33, 0.66]) {
out.push(`<line x1="${pt(hx, hy, th / 2, leafLen * f).split(",")[0]}" y1="${pt(hx, hy, th / 2, leafLen * f).split(",")[1]}" x2="${pt(hx, hy, -th / 2, leafLen * f).split(",")[0]}" y2="${pt(hx, hy, -th / 2, leafLen * f).split(",")[1]}" stroke="${theme.doorLeaf}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.6"/>`);
}
} else {
out.push(`<line x1="${pt(hx, hy, 0, leafLen * 0.12).split(",")[0]}" y1="${pt(hx, hy, 0, leafLen * 0.12).split(",")[1]}" x2="${pt(hx, hy, 0, leafLen * 0.88).split(",")[0]}" y2="${pt(hx, hy, 0, leafLen * 0.88).split(",")[1]}" stroke="${theme.window}" stroke-width="${(th * 0.4).toFixed(1)}" opacity="0.6" stroke-linecap="round"/>`);
}
// Handle + hinge dot.
const handle = pt(hx, hy, 0, leafLen * 0.82).split(",");
out.push(`<circle cx="${handle[0]}" cy="${handle[1]}" r="${(gs * 0.028).toFixed(1)}" fill="#c9a227"/>`);
out.push(`<circle cx="${hx.toFixed(1)}" cy="${hy.toFixed(1)}" r="${(gs * 0.03).toFixed(1)}" fill="${theme.doorFrame}"/>`);
if (s.locked) {
const mx = (hx + ox) / 2, my = (hy + oy) / 2;
out.push(
`<circle cx="${mx.toFixed(1)}" cy="${my.toFixed(1)}" r="${(gs * 0.07).toFixed(1)}" fill="#d4a017" stroke="#3a2c00" stroke-width="${(gs * 0.015).toFixed(1)}"/>` +
`<rect x="${(mx - gs * 0.016).toFixed(1)}" y="${(my - gs * 0.004).toFixed(1)}" width="${(gs * 0.032).toFixed(1)}" height="${(gs * 0.04).toFixed(1)}" fill="#3a2c00"/>`
);
}
out.push(`</g>`);
return out.join("");
}
// --- window symbol (era-aware artwork) -------------------------------------
function windowSymbol(s, gs, theme, sill, subtype) {
const x1 = PX(s.x1, gs), y1 = PX(s.y1, gs), x2 = PX(s.x2, gs), y2 = PX(s.y2, gs);
const L = Math.hypot(x2 - x1, y2 - y1) || 1;
const ux = (x2 - x1) / L, uy = (y2 - y1) / L;
const px = -uy, py = ux; // perpendicular (sill width)
const out = [`<g>`];
// Frame band, then glass band.
out.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${theme.windowFrame}" stroke-width="${(sill * 1.1).toFixed(1)}" stroke-linecap="butt"/>`);
out.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${theme.window}" stroke-width="${(sill * 0.62).toFixed(1)}" stroke-linecap="butt" opacity="0.92"/>`);
// Highlight streak.
out.push(`<line x1="${(x1 + px * sill * 0.14).toFixed(1)}" y1="${(y1 + py * sill * 0.14).toFixed(1)}" x2="${(x2 + px * sill * 0.14).toFixed(1)}" y2="${(y2 + py * sill * 0.14).toFixed(1)}" stroke="#ffffff" stroke-width="${(gs * 0.01).toFixed(1)}" opacity="0.5"/>`);
const bar = (f) => {
const bx = x1 + (x2 - x1) * f, by = y1 + (y2 - y1) * f;
out.push(`<line x1="${(bx - px * sill * 0.55).toFixed(1)}" y1="${(by - py * sill * 0.55).toFixed(1)}" x2="${(bx + px * sill * 0.55).toFixed(1)}" y2="${(by + py * sill * 0.55).toFixed(1)}" stroke="${theme.windowFrame}" stroke-width="${(gs * 0.02).toFixed(1)}"/>`);
};
if (subtype === "fantasy") {
bar(0.33); bar(0.66); // leaded panes
} else {
bar(0.5); // single mullion
}
out.push(`</g>`);
return out.join("");
}
function escapeXml(str) {
return String(str).replace(/[<>&'"]/g, (c) => (
{ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[c]
));
}