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>
This commit is contained in:
slaguru666
2026-06-17 15:05:25 +01:00
co-authored by Claude Opus 4.8
commit 100e514c4d
28 changed files with 3771 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* Build an HTML harness that renders one generated SVG two ways:
* left = inline in the DOM (what the preview does — lenient HTML parser)
* right = rasterised to a <canvas> via Image (what scene.mjs does)
* Used with Playwright to reproduce the "missing content in scene" bug.
*/
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { Rng } from "../module/lib/rng.mjs";
import { generateBuilding } from "../module/lib/building.mjs";
import { buildWalls } from "../module/lib/walls.mjs";
import { renderSVG } from "../module/lib/render-vector.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const layout = generateBuilding({ cols: 26, rows: 18, targetRooms: 11, subtype: "modern", seed: "mire-680", shape: "rectangle" });
const walls = buildWalls(layout, { rng: new Rng("mire-680::walls::0") });
const svg = renderSVG({ layout, walls, gridSize: 100, subtype: "modern", seed: "mire-680::0" });
const W = layout.cols * 100, H = layout.rows * 100;
const html = `<!doctype html><html><head><meta charset="utf-8"><style>
body{margin:0;background:#444;font-family:sans-serif;color:#fff}
.row{display:flex;gap:8px;padding:8px}
.cell{flex:1}.cell h3{margin:4px}
svg,canvas{width:100%;height:auto;border:1px solid #000;background:#888}
</style></head><body>
<div class="row">
<div class="cell"><h3>A: inline SVG (preview)</h3><div id="inline">${svg}</div></div>
<div class="cell"><h3 id="status">B: SVG→canvas</h3><canvas id="cv" width="${W}" height="${H}"></canvas></div>
<div class="cell"><h3 id="status2">C: →PNG blob→reload (final scene bytes)</h3><canvas id="cv2" width="${W}" height="${H}"></canvas></div>
</div>
<script>
const svgText = ${JSON.stringify(svg)};
const W=${W}, H=${H};
const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const img = new Image(); img.width=W; img.height=H;
img.onload = async () => {
try { if (img.decode) await img.decode(); } catch(e){}
const cv = document.getElementById("cv");
cv.getContext("2d").drawImage(img, 0, 0, W, H);
document.getElementById("status").textContent = "B: SVG→canvas — OK";
// C: exactly what scene.mjs uploads — toBlob PNG, then reload it
cv.toBlob((png) => {
if (!png) { document.getElementById("status2").textContent = "C: toBlob NULL"; window.__done = true; return; }
const purl = URL.createObjectURL(png);
const img2 = new Image();
img2.onload = () => {
document.getElementById("cv2").getContext("2d").drawImage(img2, 0, 0, W, H);
document.getElementById("status2").textContent = "C: PNG " + Math.round(png.size/1024) + "KB — OK";
window.__done = true;
};
img2.onerror = () => { document.getElementById("status2").textContent = "C: PNG RELOAD ERROR"; window.__done = true; };
img2.src = purl;
}, "image/png");
};
img.onerror = (e) => { document.getElementById("status").textContent = "B: IMAGE ERROR"; window.__done = true; };
img.src = url;
</script>
</body></html>`;
writeFileSync(join(here, "raster-test.html"), html);
console.log("wrote", join(here, "raster-test.html"), "size", svg.length);
+24
View File
@@ -0,0 +1,24 @@
/** Dev harness for the cave engine. node dev/render-cave-sample.mjs */
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { generateCave } from "../module/lib/dungeon.mjs";
import { renderCaveSVG } from "../module/lib/render-cave.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const SAMPLES = [
{ name: "cave-stone", theme: "cave", cols: 30, rows: 22, seed: "deep-4" },
{ name: "cave-crypt", theme: "crypt", cols: 28, rows: 20, seed: "tomb-7" },
{ name: "cave-sewer", theme: "sewer", cols: 26, rows: 20, seed: "drain-2" }
];
for (const cfg of SAMPLES) {
const model = generateCave({ cols: cfg.cols, rows: cfg.rows, seed: cfg.seed });
const svg = renderCaveSVG({ model, gridSize: 70, theme: cfg.theme, seed: cfg.seed });
const segCount = model.segments.length;
console.log(`${cfg.name.padEnd(12)} loops:${model.loops.length} segments:${segCount} features:${model.features.length}`);
writeFileSync(join(here, `${cfg.name}.svg`), svg);
}
console.log("\nWrote cave samples to", here);
+24
View File
@@ -0,0 +1,24 @@
/** Dev harness for the outdoor engine. node dev/render-outdoor-sample.mjs */
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { generateOutdoor } from "../module/lib/outdoor.mjs";
import { renderOutdoorSVG } from "../module/lib/render-outdoor.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const SAMPLES = [
{ name: "out-grass", biome: "grassland", cols: 28, rows: 20, seed: "meadow-5" },
{ name: "out-desert", biome: "desert", cols: 28, rows: 20, seed: "dune-3" },
{ name: "out-snow", biome: "snow", cols: 26, rows: 20, seed: "frost-8" },
{ name: "out-badlands", biome: "badlands", cols: 30, rows: 20, seed: "mesa-2" }
];
for (const cfg of SAMPLES) {
const model = generateOutdoor({ cols: cfg.cols, rows: cfg.rows, biome: cfg.biome, seed: cfg.seed });
const svg = renderOutdoorSVG({ model, gridSize: 70, seed: cfg.seed });
console.log(`${cfg.name.padEnd(14)} trees:${model.trees.length} rocks:${model.rocks.length} water:${model.water ? "y" : "n"} segs:${model.segments.length}`);
writeFileSync(join(here, `${cfg.name}.svg`), svg);
}
console.log("\nWrote outdoor samples to", here);
+28
View File
@@ -0,0 +1,28 @@
/** Dev harness — building engine + furniture placement. node dev/render-sample.mjs */
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { Rng } from "../module/lib/rng.mjs";
import { generateBuilding } from "../module/lib/building.mjs";
import { buildWalls } from "../module/lib/walls.mjs";
import { computePlacements } from "../module/lib/furniture-layout.mjs";
import { renderSVG } from "../module/lib/render-vector.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const SAMPLES = [
{ name: "modern-rect", subtype: "modern", shape: "rectangle", cols: 26, rows: 18, targetRooms: 11, seed: "office-7" },
{ name: "fantasy-u", subtype: "fantasy", shape: "u", cols: 28, rows: 22, targetRooms: 12, seed: "keep-3" },
{ name: "modern-plus", subtype: "modern", shape: "plus", cols: 30, rows: 24, targetRooms: 14, seed: "hub-2" }
];
for (const cfg of SAMPLES) {
const layout = generateBuilding(cfg);
const walls = buildWalls(layout, { rng: new Rng(`${cfg.seed}::walls`) });
const placements = computePlacements(layout, walls, 70, cfg.subtype, cfg.seed);
const svg = renderSVG({ layout, walls, gridSize: 70, subtype: cfg.subtype, seed: cfg.seed, placements, showFurniture: true });
console.log(`${cfg.name.padEnd(14)} rooms:${layout.rooms.length} furniture:${placements.length} doors:${walls.doors.length}`);
writeFileSync(join(here, `${cfg.name}.svg`), svg);
}
console.log("\nWrote building samples to", here);
+22
View File
@@ -0,0 +1,22 @@
/** Dev harness for the settlement engine. node dev/render-settlement-sample.mjs */
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { generateSettlement } from "../module/lib/settlement.mjs";
import { renderSettlementSVG } from "../module/lib/render-settlement.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const SAMPLES = [
{ name: "city-block", style: "city", cols: 34, rows: 24, seed: "downtown-5" },
{ name: "village", style: "village", cols: 34, rows: 24, seed: "hamlet-3" }
];
for (const cfg of SAMPLES) {
const model = generateSettlement({ cols: cfg.cols, rows: cfg.rows, style: cfg.style, seed: cfg.seed });
const svg = renderSettlementSVG({ model, gridSize: 70, seed: cfg.seed });
console.log(`${cfg.name.padEnd(12)} buildings:${model.buildings.length} walls:${model.segments.length} trees:${model.trees.length} plots:${model.plots.length}`);
writeFileSync(join(here, `${cfg.name}.svg`), svg);
}
console.log("\nWrote settlement samples to", here);