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
+314
View File
@@ -0,0 +1,314 @@
/**
* Mapwright generator window (ApplicationV2 + Handlebars).
*
* Simple for inexperienced GMs: pick a category + style + size, watch the LIVE
* preview, hit Create Map. Preview uses the exact same pipeline as the final
* scene. Routes to building (single or multi-floor) / cave / outdoor engines.
*/
import { Rng } from "../lib/rng.mjs";
import { generateBuilding, generateBuildingComplex } from "../lib/building.mjs";
import { buildWalls } from "../lib/walls.mjs";
import { computePlacements } from "../lib/furniture-layout.mjs";
import { renderSVG } from "../lib/render-vector.mjs";
import { generateCave } from "../lib/dungeon.mjs";
import { renderCaveSVG } from "../lib/render-cave.mjs";
import { generateOutdoor } from "../lib/outdoor.mjs";
import { renderOutdoorSVG } from "../lib/render-outdoor.mjs";
import { generateSettlement } from "../lib/settlement.mjs";
import { renderSettlementSVG } from "../lib/render-settlement.mjs";
import {
buildRoomNotes, buildRoomLights, buildFeatureNotes, buildFeatureLights
} from "../lib/population.mjs";
import { createScene, createSceneSet } from "../lib/scene.mjs";
const MODULE_ID = "mapwright";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const CATEGORIES = [
{ value: "modern", label: "Modern Building" },
{ value: "fantasy", label: "Fantasy Building" },
{ value: "cave", label: "Cave / Dungeon" },
{ value: "outdoor", label: "Outdoors" },
{ value: "village", label: "Town / Village" },
{ value: "city", label: "City Block" }
];
const isSettlementCat = (c) => c === "village" || c === "city";
const VARIANTS = {
modern: [], fantasy: [],
cave: [
{ value: "cave", label: "Rough Cave" },
{ value: "crypt", label: "Undead Crypt" },
{ value: "sewer", label: "Sewer / Undercroft" }
],
outdoor: [
{ value: "grassland", label: "Grassland" },
{ value: "desert", label: "Desert" },
{ value: "snow", label: "Snow" },
{ value: "badlands", label: "Badlands" }
]
};
const SHAPES = [
{ value: "auto", label: "Auto (random)" },
{ value: "rectangle", label: "Rectangle" },
{ value: "l", label: "L-Shape" },
{ value: "t", label: "T-Shape" },
{ value: "u", label: "U-Shape" },
{ value: "plus", label: "Cross / Plus" }
];
const FLOORS = [1, 2, 3, 4];
const SIZE_PRESETS = {
small: { label: "Small", cols: 18, rows: 14, targetRooms: 6 },
medium: { label: "Medium", cols: 26, rows: 18, targetRooms: 11 },
large: { label: "Large", cols: 34, rows: 24, targetRooms: 18 }
};
const GRID_SIZES = [
{ value: 70, label: "70 px" },
{ value: 100, label: "100 px (standard)" },
{ value: 140, label: "140 px" }
];
const BG = {
modern: "#23262b", fantasy: "#2a241c",
cave: "#0f0d0b", crypt: "#0c0e12", sewer: "#0c100e",
grassland: "#5b7a3a", desert: "#c9a96a", snow: "#dfe6ec", badlands: "#9c6a44",
village: "#5d7c3c", city: "#2f3338"
};
const isBuildingCat = (c) => c === "modern" || c === "fantasy";
/** Build one building-floor MapResult (shared by single + multi-floor). */
function buildingFloor(layout, cfg, subtype, gs, floorIndex, floorNames) {
const walls = buildWalls(layout, { rng: new Rng(`${cfg.seed}::walls::${floorIndex}`), windows: cfg.windows });
const placements = cfg.furniture ? computePlacements(layout, walls, gs, subtype, `${cfg.seed}::${floorIndex}`) : [];
const common = { layout, walls, gridSize: gs, subtype, seed: `${cfg.seed}::${floorIndex}`, showGrid: cfg.showGrid, showLabels: cfg.showLabels };
// Background image has NO furniture (furniture is placed as locked Tiles);
// the preview draws the furniture vector so it stays WYSIWYG.
const svg = renderSVG({ ...common, showFurniture: false });
const previewSvg = renderSVG({ ...common, showFurniture: cfg.furniture, placements });
const notes = cfg.createNotes ? buildRoomNotes(layout, gs) : [];
if (cfg.createNotes && layout.circulation && floorNames) {
const up = floorNames[floorIndex + 1], down = floorNames[floorIndex - 1];
const parts = [];
if (down) parts.push(`Down → ${down}`);
if (up) parts.push(`Up → ${up}`);
if (parts.length) notes.push(...buildFeatureNotes([{ x: layout.circulation.x, y: layout.circulation.y, label: parts.join(" · ") }], gs));
}
return {
width: layout.cols * gs, height: layout.rows * gs, gridSize: gs,
backgroundColor: BG[subtype], svg, previewSvg, placements, segments: walls.segments, notes,
lights: cfg.placeLights ? buildRoomLights(layout, gs, subtype) : [],
tokenVision: !!cfg.placeLights, floorName: layout.floorName,
stats: `${layout.rooms.length} rooms · ${walls.doors.length} doors · ${placements.length} props`
};
}
/** Uniform MapResult (or multi-floor wrapper) for any category. */
function compose(cfg) {
const gs = cfg.gridSize;
if (isBuildingCat(cfg.category)) {
const subtype = cfg.category;
const floors = Math.max(1, cfg.floors || 1);
if (floors > 1) {
const complex = generateBuildingComplex({
cols: cfg.cols, rows: cfg.rows, targetRooms: cfg.targetRooms,
subtype, seed: cfg.seed, shape: cfg.shape, floors
});
const results = complex.floors.map((layout, i) => buildingFloor(layout, cfg, subtype, gs, i, complex.floorNames));
return { multi: true, floors: results, preview: results[0].previewSvg, width: results[0].width, height: results[0].height, stats: `${floors} floors · ${results[0].stats}` };
}
const layout = generateBuilding({ cols: cfg.cols, rows: cfg.rows, targetRooms: cfg.targetRooms, subtype, seed: cfg.seed, shape: cfg.shape });
return buildingFloor(layout, cfg, subtype, gs, 0, null);
}
if (cfg.category === "cave") {
const theme = cfg.variant || "cave";
const model = generateCave({ cols: cfg.cols, rows: cfg.rows, seed: cfg.seed });
const svg = renderCaveSVG({ model, gridSize: gs, theme, seed: cfg.seed, showGrid: cfg.showGrid, showLabels: cfg.showLabels });
return {
width: cfg.cols * gs, height: cfg.rows * gs, gridSize: gs, backgroundColor: BG[theme] ?? BG.cave, svg,
segments: model.segments,
notes: cfg.createNotes ? buildFeatureNotes(model.features, gs) : [],
lights: cfg.placeLights ? buildFeatureLights(model.features, gs) : [],
tokenVision: !!cfg.placeLights,
stats: `${model.loops.length} chambers · ${model.features.length} features`
};
}
if (isSettlementCat(cfg.category)) {
const model = generateSettlement({ cols: cfg.cols, rows: cfg.rows, style: cfg.category, seed: cfg.seed });
const svg = renderSettlementSVG({ model, gridSize: gs, seed: cfg.seed, showGrid: cfg.showGrid, showLabels: cfg.showLabels });
return {
width: cfg.cols * gs, height: cfg.rows * gs, gridSize: gs, backgroundColor: BG[cfg.category], svg,
segments: model.segments,
notes: cfg.createNotes ? buildFeatureNotes(model.features, gs) : [],
lights: [], tokenVision: false,
stats: `${model.buildings.length} buildings · ${model.segments.length} wall segments`
};
}
const biome = cfg.variant || "grassland";
const model = generateOutdoor({ cols: cfg.cols, rows: cfg.rows, biome, seed: cfg.seed });
const svg = renderOutdoorSVG({ model, gridSize: gs, seed: cfg.seed, showGrid: cfg.showGrid, showLabels: cfg.showLabels });
return {
width: cfg.cols * gs, height: cfg.rows * gs, gridSize: gs, backgroundColor: BG[biome] ?? BG.grassland, svg,
segments: model.segments,
notes: cfg.createNotes ? buildFeatureNotes(model.features, gs) : [],
lights: [], tokenVision: false,
stats: `${model.trees.length} trees · ${model.rocks.length} rocks · water ${model.water ? "yes" : "no"}`
};
}
function randomSeed() {
const words = ["amber", "raven", "iron", "hollow", "vault", "ash", "north", "grim", "pale", "oak", "mire", "stone"];
return `${words[Math.floor(Math.random() * words.length)]}-${Math.floor(Math.random() * 900 + 100)}`;
}
export class MapwrightApp extends HandlebarsApplicationMixin(ApplicationV2) {
#busy = false;
static DEFAULT_OPTIONS = {
id: MODULE_ID,
classes: ["mapwright"],
tag: "div",
window: { title: "MAPWRIGHT.Title", resizable: true, icon: "fas fa-map" },
position: { width: 1060, height: 760 },
actions: { reroll: MapwrightApp.#onReroll, create: MapwrightApp.#onCreate }
};
static PARTS = { body: { template: `modules/${MODULE_ID}/templates/generator.html` } };
static open() { return new MapwrightApp().render({ force: true }); }
async _prepareContext() {
const defaults = {
name: "New Map", category: "modern", shape: "auto", floors: 1, size: "medium",
gridSize: 100, seed: randomSeed(), windows: true, furniture: true,
placeLights: false, createNotes: true, showGrid: true, showLabels: true
};
return {
categories: CATEGORIES.map((o) => ({ ...o, selected: o.value === defaults.category })),
shapes: SHAPES.map((o) => ({ ...o, selected: o.value === defaults.shape })),
floors: FLOORS.map((n) => ({ value: n, label: n === 1 ? "1 floor" : `${n} floors`, selected: n === defaults.floors })),
sizes: Object.entries(SIZE_PRESETS).map(([value, p]) => ({ value, label: p.label, selected: value === defaults.size })),
gridSizes: GRID_SIZES.map((o) => ({ ...o, selected: o.value === defaults.gridSize })),
defaults
};
}
_onRender() {
const root = this.element;
root.querySelectorAll("[data-preview]").forEach((el) => {
const evt = el.tagName === "INPUT" && el.type === "text" ? "input" : "change";
el.addEventListener(evt, () => {
if (el.name === "category") this.#syncCategory();
this.#debouncedPreview();
});
});
this.#syncCategory();
this.#updatePreview();
}
#syncCategory() {
const root = this.element;
const cat = root.querySelector('[name="category"]')?.value ?? "modern";
const building = isBuildingCat(cat);
const variants = VARIANTS[cat] ?? [];
const variantField = root.querySelector(".mapwright-variant");
const variantSelect = root.querySelector('[name="variant"]');
if (variantSelect) {
variantSelect.innerHTML = variants.map((v) => `<option value="${v.value}">${v.label}</option>`).join("");
variantField.style.display = variants.length ? "" : "none";
}
const show = (sel, on) => { const el = root.querySelector(sel); if (el) el.style.display = on ? "" : "none"; };
show(".mapwright-shape", building);
show(".mapwright-floors", building);
show(".mw-opt-windows", building);
show(".mw-opt-furniture", building);
show(".mw-opt-lights", building || cat === "cave");
}
#readConfig() {
const root = this.element;
const v = (name) => root.querySelector(`[name="${name}"]`);
const size = v("size")?.value ?? "medium";
const preset = SIZE_PRESETS[size] ?? SIZE_PRESETS.medium;
return {
name: (v("name")?.value || "New Map").trim(),
category: v("category")?.value ?? "modern",
variant: v("variant")?.value ?? "",
shape: v("shape")?.value ?? "rectangle",
floors: Number(v("floors")?.value ?? 1),
size, cols: preset.cols, rows: preset.rows, targetRooms: preset.targetRooms,
gridSize: Number(v("gridSize")?.value ?? 100),
seed: (v("seed")?.value || "mapwright").trim(),
windows: v("windows")?.checked ?? true,
furniture: v("furniture")?.checked ?? true,
placeLights: v("placeLights")?.checked ?? true,
createNotes: v("createNotes")?.checked ?? true,
showGrid: v("showGrid")?.checked ?? true,
showLabels: v("showLabels")?.checked ?? true
};
}
#debouncedPreview() {
clearTimeout(this._t);
this._t = setTimeout(() => this.#updatePreview(), 120);
}
#updatePreview() {
const cfg = this.#readConfig();
const host = this.element.querySelector(".mapwright-preview");
const stats = this.element.querySelector(".mapwright-stats");
if (!host) return;
try {
const result = compose(cfg);
host.innerHTML = result.previewSvg ?? result.svg ?? result.preview ?? "";
if (stats) stats.textContent = result.stats ?? "";
} catch (err) {
console.error("Mapwright preview failed", err);
host.innerHTML = `<p class="mapwright-error">Preview failed: ${err.message}</p>`;
}
}
static #onReroll() {
const seedInput = this.element.querySelector('[name="seed"]');
if (seedInput) seedInput.value = randomSeed();
this.#updatePreview();
}
static async #onCreate() {
if (this.#busy) return;
this.#busy = true;
const button = this.element.querySelector('[data-action="create"]');
if (button) button.disabled = true;
try {
const cfg = this.#readConfig();
const result = compose(cfg);
const tokenVision = result.multi ? result.floors[0]?.tokenVision : result.tokenVision;
const meta = { name: cfg.name, gridSize: cfg.gridSize, seed: cfg.seed, subtype: cfg.variant || cfg.category, tokenVision };
ui.notifications.info(`Mapwright: building "${cfg.name}"…`);
if (result.multi) {
await createSceneSet(result.floors, meta);
} else {
const scene = await createScene(result, meta);
await scene?.view?.();
}
ui.notifications.info(`Mapwright: "${cfg.name}" created.`);
this.close();
} catch (err) {
console.error("Mapwright generation failed", err);
ui.notifications.error(`Mapwright failed: ${err.message}`);
} finally {
this.#busy = false;
if (button) button.disabled = false;
}
}
}