/** * 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) => ``).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 = `
Preview failed: ${err.message}
`; } } 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; } } }