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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user