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>
315 lines
13 KiB
JavaScript
315 lines
13 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
}
|
|
}
|