const MODULE_ID = "quick-battlemap-builder"; const ROOT_SCENE_FOLDER = "Quick Battle Maps"; const GENERATED_WALL_FLAG = `${MODULE_ID}.generatedWall`; const GENERATED_DRAWING_FLAG = `${MODULE_ID}.generatedDrawing`; const QBM_ASSET_DIR = "quick-battlemap-builder"; const GRID_PRESETS = [ { value: "square", label: "Square Grid" }, { value: "hexRows", label: "Hex Grid (Rows)" }, { value: "hexColumns", label: "Hex Grid (Columns)" } ]; const COORDINATE_PRESETS = [ { value: "none", label: "Off" }, { value: "border", label: "Border Labels" }, { value: "full", label: "Full Cell Labels" } ]; const TERRAIN_PRESETS = [ { value: "grass", label: "Grass" }, { value: "concrete", label: "Concrete" }, { value: "dirt", label: "Dirt" }, { value: "metal", label: "Metal Deck" }, { value: "sand", label: "Sand" } ]; const WALL_STAMPS = [ { value: "room", label: "Room Box" }, { value: "corridorH", label: "Corridor (Horizontal)" }, { value: "corridorV", label: "Corridor (Vertical)" }, { value: "cross", label: "Cross Junction" }, { value: "lShape", label: "L Turn" } ]; const OUTLINE_STAMPS = [ { value: "buildingShell", label: "Building Shell" }, { value: "warehouseOffice", label: "Warehouse + Office" }, { value: "courtyardCompound", label: "Courtyard Compound" }, { value: "dungeonLoop", label: "Dungeon Loop" } ]; const GENERATOR_PRESETS = [ { value: "building", label: "Random Building" }, { value: "dungeon", label: "Random Dungeon" } ]; const PRESET_NOTES = [ { title: "Fast start", copy: "Create a grid scene in one click with size, terrain, and coordinates." }, { title: "Wall stamps", copy: "Drop simple rooms and corridors, then refine with Foundry’s own wall tools." }, { title: "Reusable", copy: "System-agnostic module with scene-directory access, not hard-wired to SLA only." } ]; Hooks.once("init", () => { game.quickBattleMapBuilder = { open: () => QuickBattleMapBuilderApp.open() }; game.settings.registerMenu(MODULE_ID, "openBuilder", { name: game.i18n.localize("QBM.MenuName"), hint: game.i18n.localize("QBM.MenuHint"), label: game.i18n.localize("QBM.SceneButton"), icon: "fas fa-map", type: QuickBattleMapBuilderApp, restricted: true }); }); Hooks.on("renderSceneDirectory", (_app, html) => { installSceneDirectoryButton(html); }); Hooks.on("renderSidebarTab", (app, html) => { const isScenesTab = app?.tabName === "scenes" || app?.options?.id === "scenes"; if (!isScenesTab) return; installSceneDirectoryButton(html); }); Hooks.on("getSceneControlButtons", (controls) => { if (!game.user?.isGM) return; const buttonTool = { name: "openQuickBattleMapBuilder", title: game.i18n.localize("QBM.ToolbarOpen"), icon: "fas fa-map-marked-alt", button: true, onClick: () => QuickBattleMapBuilderApp.open() }; if (Array.isArray(controls)) { const wallsControls = controls.find((control) => control.name === "walls"); if (wallsControls) { wallsControls.tools ??= []; if (!wallsControls.tools.some((tool) => tool.name === buttonTool.name)) { wallsControls.tools.push(buttonTool); } return; } controls.push({ name: MODULE_ID, title: game.i18n.localize("QBM.ToolbarGroup"), icon: "fas fa-map", layer: "walls", tools: [buttonTool] }); return; } if (!controls || typeof controls !== "object") return; const wallsControls = controls.walls; if (wallsControls) { wallsControls.tools ??= {}; if (!wallsControls.tools[buttonTool.name]) { wallsControls.tools[buttonTool.name] = buttonTool; } return; } controls[MODULE_ID] = { name: MODULE_ID, title: game.i18n.localize("QBM.ToolbarGroup"), icon: "fas fa-map", layer: "walls", tools: { [buttonTool.name]: buttonTool } }; }); class QuickBattleMapBuilderApp extends FormApplication { static lastSceneId = ""; static lastFormData = {}; static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: MODULE_ID, classes: ["quick-battlemap-builder"], template: `modules/${MODULE_ID}/templates/quick-battlemap-builder.html`, width: 720, height: "auto", submitOnChange: false, closeOnSubmit: false }); } get title() { return game.i18n.localize("QBM.AppTitle"); } async getData() { const saved = QuickBattleMapBuilderApp.lastFormData ?? {}; const activeSceneId = QuickBattleMapBuilderApp.lastSceneId || canvas.scene?.id || game.scenes?.active?.id || ""; const defaults = { sceneName: buildSceneName(), sceneFolder: ROOT_SCENE_FOLDER, columns: 24, rows: 18, gridSize: 100, distance: 1, units: game.system?.grid?.units ?? game.system?.gridUnits ?? "meter", activateScene: true, applyOutlineOnCreate: true, addBorderWalls: true, enableTokenVision: false, startX: 2, startY: 2, stampWidth: 8, stampHeight: 6, generatorType: "building", generatorRooms: 8, terrain: "grass", gridPreset: "square", coordinates: "border", outlineType: OUTLINE_STAMPS[0].value, stampType: WALL_STAMPS[0].value, sceneId: activeSceneId }; const mergedDefaults = { ...defaults, ...saved, sceneId: saved.sceneId || activeSceneId || defaults.sceneId }; return { defaults: mergedDefaults, gridOptions: GRID_PRESETS.map((option) => ({ ...option, selected: option.value === mergedDefaults.gridPreset })), terrainOptions: TERRAIN_PRESETS.map((option) => ({ ...option, selected: option.value === mergedDefaults.terrain })), coordinateOptions: COORDINATE_PRESETS.map((option) => ({ ...option, selected: option.value === mergedDefaults.coordinates })), stampOptions: WALL_STAMPS.map((option) => ({ ...option, selected: option.value === mergedDefaults.stampType })), outlineOptions: OUTLINE_STAMPS.map((option) => ({ ...option, selected: option.value === mergedDefaults.outlineType })), generatorOptions: GENERATOR_PRESETS.map((option) => ({ ...option, selected: option.value === mergedDefaults.generatorType })), sceneOptions: (game.scenes ?? []).map((scene) => ({ value: scene.id, label: scene.name, selected: scene.id === mergedDefaults.sceneId })), presetNotes: PRESET_NOTES }; } activateListeners(html) { super.activateListeners(html); html.find(".qbm-create-scene").on("click", async () => { const formData = getFormData(this.form); rememberFormData(formData); const scene = await createBattleMapScene(formData); if (!scene) return; QuickBattleMapBuilderApp.lastSceneId = scene.id; QuickBattleMapBuilderApp.lastFormData.sceneId = scene.id; this.render(false); }); html.find(".qbm-add-stamp").on("click", async () => { const formData = getFormData(this.form); rememberFormData(formData); await addWallStampToScene(formData); this.render(false); }); html.find(".qbm-add-outline").on("click", async () => { const formData = getFormData(this.form); rememberFormData(formData); await addStructureOutline(formData); this.render(false); }); html.find(".qbm-generate-layout").on("click", async () => { const formData = getFormData(this.form); rememberFormData(formData); await addGeneratedLayout(formData); this.render(false); }); html.find(".qbm-add-perimeter").on("click", async () => { const formData = getFormData(this.form); rememberFormData(formData); await addScenePerimeterWalls(formData.sceneId); this.render(false); }); html.find(".qbm-clear-generated").on("click", async () => { const formData = getFormData(this.form); rememberFormData(formData); await clearGeneratedWalls(formData.sceneId); this.render(false); }); } async _updateObject() {} static open() { return new QuickBattleMapBuilderApp().render({ force: true }); } } function installSceneDirectoryButton(html) { if (!game.user?.isGM) return; const root = $(html); if (root.find(".qbm-launch").length) return; const button = $(` `); button.on("click", () => QuickBattleMapBuilderApp.open()); const createSceneButton = findSceneCreateButton(root); if (createSceneButton?.length) { const existingRow = createSceneButton.closest(".qbm-scene-directory-actions"); if (existingRow.length) { existingRow.append(button); return; } const row = $('
'); row.append(button); createSceneButton.closest(".header-actions, .action-buttons, .directory-header, header, .tab").first().after(row); return; } const actionBar = findSceneActionBar(root); if (actionBar?.length) { actionBar.append(button); return; } const header = root.find(".directory-header, header, .tab[data-tab='scenes']").first(); if (header.length) { const row = $(''); row.append(button); header.prepend(row); } } function findSceneCreateButton(root) { const selectors = [ "button[data-action='createScene']", "a[data-action='createScene']", "button.create-document", "a.create-document", "button[data-action='createDocument']", "a[data-action='createDocument']" ]; for (const selector of selectors) { const found = root.find(selector); if (found.length) return found.first(); } const allButtons = root.find("button, a"); const match = allButtons.filter((_, el) => { const text = String(el.textContent ?? "").toLowerCase(); return text.includes("create scene"); }); return match.length ? match.first() : null; } function findSceneActionBar(root) { const selectors = [ ".directory-header .header-actions", ".directory-header .action-buttons", ".scene-directory .header-actions", ".scene-directory .action-buttons", ".tab.scenes .header-actions", ".tab[data-tab='scenes'] .header-actions", ".tab[data-tab='scenes'] .action-buttons" ]; for (const selector of selectors) { const found = root.find(selector); if (found.length) return found.first(); } return null; } async function createBattleMapScene(formData) { const columns = clampInt(formData.columns, 4, 80, 24); const rows = clampInt(formData.rows, 4, 80, 18); const gridSize = clampInt(formData.gridSize, 40, 256, 100); const distance = clampInt(formData.distance, 1, 100, 1); const name = String(formData.sceneName || buildSceneName()).trim(); const terrain = normalizeChoice(formData.terrain, TERRAIN_PRESETS[0].value, TERRAIN_PRESETS.map((entry) => entry.value)); const coordinates = normalizeChoice(formData.coordinates, "border", COORDINATE_PRESETS.map((entry) => entry.value)); const gridPreset = normalizeChoice(formData.gridPreset, "square", GRID_PRESETS.map((entry) => entry.value)); const width = columns * gridSize; const height = rows * gridSize; const folder = await ensureSceneFolder(String(formData.sceneFolder || ROOT_SCENE_FOLDER).trim() || ROOT_SCENE_FOLDER); const generatedLayout = Boolean(formData.applyOutlineOnCreate) ? buildGeneratedLayoutFromContext({ sceneWidth: width, sceneHeight: height, gridSize, formData, terrain, preferSceneFill: true }) : null; const backgroundSrc = await createSceneBackgroundAsset({ name, terrain, width, height, columns, rows, gridSize, coordinates }); const sceneData = { name, folder: folder?.id ?? null, width, height, padding: 0, tokenVision: Boolean(formData.enableTokenVision), fog: { exploration: false }, grid: { type: resolveGridType(gridPreset), size: gridSize, distance, units: String(formData.units || "meter").trim() || "meter", alpha: 0.35, color: "#1a1a1a" }, backgroundColor: terrainBaseColor(terrain), initial: { scale: 1 } }; if (backgroundSrc) { sceneData.background = { src: backgroundSrc }; sceneData.thumb = backgroundSrc; } let scene = null; try { scene = await Scene.create(sceneData); } catch (error) { console.error(`${MODULE_ID} | Failed to create battle map scene`, error); ui.notifications.error(`Quick battle map scene creation failed: ${error?.message ?? error}`); return null; } if (!scene) return null; if (Boolean(formData.addBorderWalls)) { await createWalls(scene, buildWallPreset("perimeter", { sceneWidth: scene.width, sceneHeight: scene.height, gridSize })); } if (generatedLayout?.drawings?.length) { await createDrawings(scene, generatedLayout.drawings); } if (generatedLayout?.walls?.length) { await createWalls(scene, generatedLayout.walls); } if (Boolean(formData.activateScene)) { await scene.activate(); await scene.view(); } ui.notifications.info(`Quick battle map ready: ${scene.name}`); return scene; } async function addWallStampToScene(formData) { const scene = resolveScene(formData.sceneId); if (!scene) { ui.notifications.warn("Choose a target scene first."); return null; } const stampType = normalizeChoice(formData.stampType, "room", WALL_STAMPS.map((entry) => entry.value)); const gridSize = Number(scene.grid?.size ?? 100) || 100; const startX = Math.max(0, Number(formData.startX ?? 0) || 0); const startY = Math.max(0, Number(formData.startY ?? 0) || 0); const stampWidth = Math.max(1, Number(formData.stampWidth ?? 1) || 1); const stampHeight = Math.max(1, Number(formData.stampHeight ?? 1) || 1); const walls = buildWallPreset(stampType, { sceneWidth: scene.width, sceneHeight: scene.height, gridSize, x: startX * gridSize, y: startY * gridSize, width: stampWidth * gridSize, height: stampHeight * gridSize }); if (!walls.length) return null; await createWalls(scene, walls); ui.notifications.info(`Added ${stampType} wall stamp to ${scene.name}.`); return walls.length; } async function addScenePerimeterWalls(sceneId) { const scene = resolveScene(sceneId); if (!scene) { ui.notifications.warn("Choose a target scene first."); return null; } const walls = buildWallPreset("perimeter", { sceneWidth: scene.width, sceneHeight: scene.height, gridSize: Number(scene.grid?.size ?? 100) || 100 }); await createWalls(scene, walls); ui.notifications.info(`Added perimeter walls to ${scene.name}.`); return walls.length; } async function clearGeneratedWalls(sceneId) { const scene = resolveScene(sceneId); if (!scene) { ui.notifications.warn("Choose a target scene first."); return null; } const generatedIds = scene.walls .filter((wall) => wall.flags?.[MODULE_ID]?.generatedWall) .map((wall) => wall.id); const generatedDrawingIds = scene.drawings .filter((drawing) => drawing.flags?.[MODULE_ID]?.generatedDrawing) .map((drawing) => drawing.id); if (!generatedIds.length && !generatedDrawingIds.length) { ui.notifications.info(`No generated geometry found on ${scene.name}.`); return 0; } if (generatedIds.length) { await scene.deleteEmbeddedDocuments("Wall", generatedIds); } if (generatedDrawingIds.length) { await scene.deleteEmbeddedDocuments("Drawing", generatedDrawingIds); } ui.notifications.info(`Removed ${generatedIds.length} generated walls and ${generatedDrawingIds.length} generated outlines from ${scene.name}.`); return generatedIds.length + generatedDrawingIds.length; } async function createWalls(scene, walls) { if (!walls.length) return []; return scene.createEmbeddedDocuments("Wall", walls.map((wall) => ({ ...wall, flags: { [MODULE_ID]: { generatedWall: true } } }))); } async function createDrawings(scene, drawings) { if (!drawings.length) return []; return scene.createEmbeddedDocuments("Drawing", drawings.map((drawing) => ({ ...drawing, flags: { [MODULE_ID]: { generatedDrawing: true } } }))); } function buildWallPreset(type, context) { const { sceneWidth = 0, sceneHeight = 0, x = 0, y = 0, width = 0, height = 0 } = context; switch (type) { case "perimeter": return rectangleWalls(0, 0, sceneWidth, sceneHeight); case "corridorH": return rectangleWalls(x, y, width, Math.max(height, context.gridSize * 2)); case "corridorV": return rectangleWalls(x, y, Math.max(width, context.gridSize * 2), height); case "cross": { const walls = rectangleWalls(x, y, width, height); walls.push(wallData(x + width / 2, y, x + width / 2, y + height)); walls.push(wallData(x, y + height / 2, x + width, y + height / 2)); return walls; } case "lShape": { const halfW = Math.max(context.gridSize, width / 2); const halfH = Math.max(context.gridSize, height / 2); return [ ...rectangleWalls(x, y, halfW, height), ...rectangleWalls(x, y + halfH, width, halfH) ]; } case "room": default: return rectangleWalls(x, y, width, height); } } async function addStructureOutline(formData) { const scene = resolveScene(formData.sceneId); if (!scene) { ui.notifications.warn("Choose a target scene first."); return null; } return addStructureOutlineToScene(scene, formData); } async function addStructureOutlineToScene(scene, formData) { const outlineType = normalizeChoice(formData.outlineType, OUTLINE_STAMPS[0].value, OUTLINE_STAMPS.map((entry) => entry.value)); const gridSize = Number(scene.grid?.size ?? 100) || 100; const startX = Math.max(0, Number(formData.startX ?? 0) || 0); const startY = Math.max(0, Number(formData.startY ?? 0) || 0); const stampWidth = Math.max(2, Number(formData.stampWidth ?? 2) || 2); const stampHeight = Math.max(2, Number(formData.stampHeight ?? 2) || 2); const geometry = buildOutlinePreset(outlineType, { x: startX * gridSize, y: startY * gridSize, width: stampWidth * gridSize, height: stampHeight * gridSize, gridSize }); if (!geometry.walls.length && !geometry.drawings.length) return null; await createDrawings(scene, geometry.drawings); await createWalls(scene, geometry.walls); ui.notifications.info(`Added ${geometry.label} outline to ${scene.name}.`); return geometry; } async function addGeneratedLayout(formData) { const scene = resolveScene(formData.sceneId); if (!scene) { ui.notifications.warn("Choose a target scene first."); return null; } return addGeneratedLayoutToScene(scene, formData); } async function addGeneratedLayoutToScene(scene, formData) { const layout = buildGeneratedLayoutFromContext({ sceneWidth: scene.width, sceneHeight: scene.height, gridSize: Number(scene.grid?.size ?? 100) || 100, formData, terrain: normalizeChoice(formData.terrain, TERRAIN_PRESETS[0].value, TERRAIN_PRESETS.map((entry) => entry.value)) }); if (!layout) return null; if (layout.drawings?.length) await createDrawings(scene, layout.drawings); if (layout.walls?.length) await createWalls(scene, layout.walls); ui.notifications.info(`Generated ${layout.label} on ${scene.name}.`); return layout; } function buildGeneratedLayoutFromContext({ sceneWidth, sceneHeight, gridSize, formData, terrain, preferSceneFill = false }) { const generatorType = normalizeChoice(formData.generatorType, "building", GENERATOR_PRESETS.map((entry) => entry.value)); const maxStartX = Math.max(0, Math.floor(sceneWidth / gridSize) - 4); const maxStartY = Math.max(0, Math.floor(sceneHeight / gridSize) - 4); const startX = clampInt(formData.startX, 0, maxStartX, 2); const startY = clampInt(formData.startY, 0, maxStartY, 2); const maxWidthCells = Math.max(4, Math.floor((sceneWidth - startX * gridSize) / gridSize)); const maxHeightCells = Math.max(4, Math.floor((sceneHeight - startY * gridSize) / gridSize)); const roomTarget = clampInt(formData.generatorRooms, 3, 20, 8); const suggestedWidth = Math.min( maxWidthCells, Math.max(8, Math.ceil(Math.sqrt(roomTarget)) * (generatorType === "dungeon" ? 4 : 3)) ); const suggestedHeight = Math.min( maxHeightCells, Math.max(8, Math.ceil(roomTarget / Math.max(2, Math.floor(suggestedWidth / 3))) * (generatorType === "dungeon" ? 3 : 2) + 2) ); const bounds = { x: startX * gridSize, y: startY * gridSize, width: clampInt( formData.stampWidth, 4, maxWidthCells, preferSceneFill ? Math.max(6, maxWidthCells - 2) : suggestedWidth ) * gridSize, height: clampInt( formData.stampHeight, 4, maxHeightCells, preferSceneFill ? Math.max(6, maxHeightCells - 2) : suggestedHeight ) * gridSize, gridSize }; return generatorType === "dungeon" ? buildRandomDungeonLayout({ ...bounds, roomTarget, terrain }) : buildRandomBuildingLayout({ ...bounds, roomTarget, terrain }); } function buildOutlinePreset(type, context) { const { x = 0, y = 0, width = 0, height = 0, gridSize = 100 } = context; const drawings = []; const walls = []; if (type === "warehouseOffice") { const officeWidth = Math.max(gridSize * 2, width * 0.28); const officeHeight = Math.max(gridSize * 2, height * 0.34); drawings.push(rectDrawing(x, y, width, height)); drawings.push(rectDrawing(x, y + height - officeHeight, officeWidth, officeHeight)); walls.push(...rectangleWalls(x, y, width, height)); walls.push(...rectangleWalls(x, y + height - officeHeight, officeWidth, officeHeight)); return { label: "warehouse/office", drawings, walls }; } if (type === "courtyardCompound") { const inset = Math.max(gridSize, Math.min(width, height) * 0.22); drawings.push(rectDrawing(x, y, width, height)); drawings.push(rectDrawing(x + inset, y + inset, width - inset * 2, height - inset * 2)); walls.push(...rectangleWalls(x, y, width, height)); walls.push(...rectangleWalls(x + inset, y + inset, width - inset * 2, height - inset * 2)); return { label: "courtyard compound", drawings, walls }; } if (type === "dungeonLoop") { const inset = Math.max(gridSize, Math.min(width, height) * 0.18); const entryWidth = Math.max(gridSize * 2, width * 0.24); const entryHeight = Math.max(gridSize * 2, height * 0.2); const entryX = x + (width - entryWidth) / 2; const entryY = y + height; drawings.push(rectDrawing(x, y, width, height)); drawings.push(rectDrawing(x + inset, y + inset, width - inset * 2, height - inset * 2)); drawings.push(rectDrawing(entryX, entryY, entryWidth, entryHeight)); walls.push(...rectangleWalls(x, y, width, height)); walls.push(...rectangleWalls(x + inset, y + inset, width - inset * 2, height - inset * 2)); walls.push(...rectangleWalls(entryX, entryY, entryWidth, entryHeight)); return { label: "dungeon loop", drawings, walls }; } drawings.push(rectDrawing(x, y, width, height)); walls.push(...rectangleWalls(x, y, width, height)); return { label: "building shell", drawings, walls }; } function rectangleWalls(x, y, width, height) { if (width <= 0 || height <= 0) return []; return [ wallData(x, y, x + width, y), wallData(x + width, y, x + width, y + height), wallData(x + width, y + height, x, y + height), wallData(x, y + height, x, y) ]; } function rectDrawing(x, y, width, height, color = "#111111") { return { x: round(x), y: round(y), z: 0, hidden: false, locked: false, rotation: 0, strokeWidth: 6, strokeColor: color, strokeAlpha: 1, fillType: 0, fillAlpha: 0, textAlpha: 0, bezierFactor: 0, shape: { type: "r", width: round(width), height: round(height) } }; } function filledRectDrawing(x, y, width, height, fillColor = "#d0d2d4", strokeColor = "#1d2227", fillAlpha = 0.78) { return { x: round(x), y: round(y), z: 0, hidden: false, locked: false, rotation: 0, strokeWidth: 2, strokeColor, strokeAlpha: 0.95, fillType: 1, fillColor, fillAlpha, textAlpha: 0, bezierFactor: 0, shape: { type: "r", width: round(width), height: round(height) } }; } function lineDrawing(x1, y1, x2, y2, color = "#7ec8ff", width = 8, alpha = 0.95) { return { x: round(x1), y: round(y1), z: 1, hidden: false, locked: false, rotation: 0, strokeWidth: width, strokeColor: color, strokeAlpha: alpha, fillType: 0, fillAlpha: 0, textAlpha: 0, bezierFactor: 0, shape: { type: "p", points: [0, 0, round(x2 - x1), round(y2 - y1)], closed: false } }; } function wallDataDoor(x1, y1, x2, y2) { const door = foundry.utils.getProperty(CONST, "WALL_DOOR_TYPES.DOOR") ?? 1; const closed = foundry.utils.getProperty(CONST, "WALL_DOOR_STATES.CLOSED") ?? 0; const base = wallData(x1, y1, x2, y2); base.door = door; base.ds = closed; return base; } function rememberFormData(formData = {}) { QuickBattleMapBuilderApp.lastFormData = { ...(QuickBattleMapBuilderApp.lastFormData ?? {}), ...formData }; } function buildRandomBuildingLayout({ x, y, width, height, gridSize, roomTarget = 8, terrain = "concrete" }) { const palette = getLayoutPalette(terrain, "building"); const cellW = Math.max(6, Math.round(width / gridSize)); const cellH = Math.max(6, Math.round(height / gridSize)); const occupancy = makeGrid(cellW, cellH, false); const roomType = makeGrid(cellW, cellH, ""); const drawings = []; const walls = []; const footprint = { x: 0, y: 0, w: cellW, h: cellH }; fillRectCells(occupancy, footprint, true); const roomRects = splitRectIntoExactRooms(footprint, roomTarget); for (const room of roomRects) { const roomPx = cellRectToPixels(room, x, y, gridSize); drawings.push(filledRectDrawing(roomPx.x, roomPx.y, roomPx.width, roomPx.height, palette.floor, palette.outline, 0.72)); for (let cy = room.y; cy < room.y + room.h; cy += 1) { for (let cx = room.x; cx < room.x + room.w; cx += 1) { if (occupancy[cy]?.[cx]) roomType[cy][cx] = "room"; } } } const edgeResult = buildBoundaryGeometry({ occupancy, roomType, x, y, gridSize, makeExteriorDoors: Math.max(1, Math.min(3, Math.floor((cellW + cellH) / 10))), makeWindows: true, palette }); walls.push(...edgeResult.walls); drawings.push(...edgeResult.drawings); const partitions = buildRoomPartitions(roomRects, x, y, gridSize); walls.push(...partitions.walls); drawings.push(...partitions.drawings); return { label: "random building", walls, drawings }; } function splitRectIntoExactRooms(rect, targetCount) { const rooms = [{ ...rect }]; const minimumSide = 3; while (rooms.length < targetCount) { let bestIndex = -1; let bestScore = -1; for (let i = 0; i < rooms.length; i += 1) { const room = rooms[i]; const canSplitVertical = room.w >= minimumSide * 2; const canSplitHorizontal = room.h >= minimumSide * 2; if (!canSplitVertical && !canSplitHorizontal) continue; const score = room.w * room.h; if (score > bestScore) { bestScore = score; bestIndex = i; } } if (bestIndex === -1) break; const room = rooms.splice(bestIndex, 1)[0]; const canSplitVertical = room.w >= minimumSide * 2; const canSplitHorizontal = room.h >= minimumSide * 2; const splitVertical = canSplitVertical && (!canSplitHorizontal || room.w >= room.h); if (splitVertical) { const split = randInt(minimumSide, room.w - minimumSide); rooms.push( { x: room.x, y: room.y, w: split, h: room.h }, { x: room.x + split, y: room.y, w: room.w - split, h: room.h } ); } else { const split = randInt(minimumSide, room.h - minimumSide); rooms.push( { x: room.x, y: room.y, w: room.w, h: split }, { x: room.x, y: room.y + split, w: room.w, h: room.h - split } ); } } return rooms.slice(0, targetCount); } function buildRandomDungeonLayout({ x, y, width, height, gridSize, roomTarget = 8, terrain = "concrete" }) { const palette = getLayoutPalette(terrain, "dungeon"); const cellW = Math.max(10, Math.round(width / gridSize)); const cellH = Math.max(10, Math.round(height / gridSize)); const occupancy = makeGrid(cellW, cellH, false); const roomType = makeGrid(cellW, cellH, ""); const drawings = []; const walls = []; const rooms = []; const corridorRects = []; const attempts = roomTarget * 12; for (let i = 0; i < attempts && rooms.length < roomTarget; i += 1) { const rw = randInt(3, Math.max(4, Math.min(7, cellW - 2))); const rh = randInt(3, Math.max(4, Math.min(7, cellH - 2))); const rx = randInt(1, Math.max(1, cellW - rw - 1)); const ry = randInt(1, Math.max(1, cellH - rh - 1)); const candidate = { x: rx, y: ry, w: rw, h: rh }; if (rooms.some((room) => rectsOverlap(expandRect(room, 1), candidate))) continue; rooms.push(candidate); fillRectCells(occupancy, candidate, true); fillRectCells(roomType, candidate, "room"); } for (let i = 1; i < rooms.length; i += 1) { const currentCenter = rectCenterCell(rooms[i]); let nearestIndex = 0; let nearestDistance = Number.POSITIVE_INFINITY; for (let j = 0; j < i; j += 1) { const otherCenter = rectCenterCell(rooms[j]); const distance = Math.abs(currentCenter.x - otherCenter.x) + Math.abs(currentCenter.y - otherCenter.y); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = j; } } const targetCenter = rectCenterCell(rooms[nearestIndex]); const segments = carveLHallway(targetCenter, currentCenter, occupancy, roomType, Math.random() > 0.5); corridorRects.push(...segments); } const extraConnections = Math.max(1, Math.floor(rooms.length / 3)); for (let i = 0; i < extraConnections; i += 1) { const aRoom = randomChoice(rooms); const bRoom = randomChoice(rooms.filter((room) => room !== aRoom)); if (!bRoom) continue; const segments = carveLHallway(rectCenterCell(aRoom), rectCenterCell(bRoom), occupancy, roomType, Math.random() > 0.5); corridorRects.push(...segments); } for (const room of rooms) { const roomPx = cellRectToPixels(room, x, y, gridSize); drawings.push(filledRectDrawing(roomPx.x, roomPx.y, roomPx.width, roomPx.height, palette.floor, palette.outline, 0.78)); } for (const corridor of corridorRects) { const corridorPx = cellRectToPixels(corridor, x, y, gridSize); drawings.push(filledRectDrawing(corridorPx.x, corridorPx.y, corridorPx.width, corridorPx.height, palette.corridor, palette.outline, 0.72)); } const edgeResult = buildBoundaryGeometry({ occupancy, roomType, x, y, gridSize, makeExteriorDoors: 0, makeWindows: false, palette, makeRoomCorridorDoors: true }); walls.push(...edgeResult.walls); drawings.push(...edgeResult.drawings); return { label: "random dungeon", walls, drawings }; } function buildBoundaryGeometry({ occupancy, roomType, x, y, gridSize, makeExteriorDoors = 0, makeWindows = false, makeRoomCorridorDoors = false, palette }) { const h = occupancy.length; const w = occupancy[0]?.length ?? 0; const walls = []; const drawings = []; const exteriorEdges = []; const thresholdEdges = []; const directions = [ { dx: 0, dy: -1, key: "n" }, { dx: 1, dy: 0, key: "e" }, { dx: 0, dy: 1, key: "s" }, { dx: -1, dy: 0, key: "w" } ]; for (let cy = 0; cy < h; cy += 1) { for (let cx = 0; cx < w; cx += 1) { if (!occupancy[cy]?.[cx]) continue; for (const dir of directions) { const nx = cx + dir.dx; const ny = cy + dir.dy; const currentType = roomType[cy]?.[cx] || ""; const nextType = roomType[ny]?.[nx] || ""; const edge = cellEdgeToPixels(cx, cy, dir.key, x, y, gridSize); if (nx < 0 || ny < 0 || nx >= w || ny >= h || !occupancy[ny]?.[nx]) { exteriorEdges.push(edge); continue; } if (makeRoomCorridorDoors && currentType && nextType && currentType !== nextType && currentType === "room") { thresholdEdges.push(edge); } } } } const uniqueExterior = dedupeEdges(exteriorEdges); const doorEdges = randomSample(uniqueExterior, makeExteriorDoors); const doorKeys = new Set(doorEdges.map(edgeKey)); const availableWindowEdges = uniqueExterior.filter((edge) => !doorKeys.has(edgeKey(edge))); const windowEdges = makeWindows ? randomSample(availableWindowEdges, Math.max(2, Math.floor(availableWindowEdges.length / 10))) : []; const windowKeys = new Set(windowEdges.map(edgeKey)); for (const edge of uniqueExterior) { const key = edgeKey(edge); if (doorKeys.has(key)) { walls.push(wallDataDoor(edge.x1, edge.y1, edge.x2, edge.y2)); continue; } walls.push(wallData(edge.x1, edge.y1, edge.x2, edge.y2)); if (windowKeys.has(key)) { drawings.push(lineDrawing(edge.x1, edge.y1, edge.x2, edge.y2, palette.window, 10, 0.95)); } } if (makeRoomCorridorDoors) { const uniqueThresholds = dedupeEdges(thresholdEdges).filter((edge, index) => index % 2 === 0); for (const edge of uniqueThresholds) { if (Math.random() < 0.45) { walls.push(wallDataDoor(edge.x1, edge.y1, edge.x2, edge.y2)); } } } return { walls, drawings }; } function buildRoomPartitions(roomRects, offsetX, offsetY, gridSize) { const walls = []; const drawings = []; const edges = new Map(); for (let i = 0; i < roomRects.length; i += 1) { for (let j = i + 1; j < roomRects.length; j += 1) { const a = roomRects[i]; const b = roomRects[j]; const shared = getSharedWall(a, b); if (!shared) continue; edges.set(sharedWallKey(shared), shared); } } for (const shared of edges.values()) { const segments = splitEdgeForDoor(shared, gridSize, offsetX, offsetY); for (const segment of segments.walls) { walls.push(segment.door ? wallDataDoor(segment.x1, segment.y1, segment.x2, segment.y2) : wallData(segment.x1, segment.y1, segment.x2, segment.y2)); } if (segments.doorMarker) { drawings.push(lineDrawing( segments.doorMarker.x1, segments.doorMarker.y1, segments.doorMarker.x2, segments.doorMarker.y2, "#d9a441", 8, 0.95 )); } } return { walls, drawings }; } function sharedWallKey(shared) { if (shared.orientation === "v") return `v:${shared.x}:${shared.y1}:${shared.y2}`; return `h:${shared.y}:${shared.x1}:${shared.x2}`; } function getSharedWall(a, b) { if (a.x + a.w === b.x || b.x + b.w === a.x) { const x = a.x + a.w === b.x ? b.x : a.x; const y1 = Math.max(a.y, b.y); const y2 = Math.min(a.y + a.h, b.y + b.h); if (y2 - y1 >= 2) return { orientation: "v", x, y1, y2 }; } if (a.y + a.h === b.y || b.y + b.h === a.y) { const y = a.y + a.h === b.y ? b.y : a.y; const x1 = Math.max(a.x, b.x); const x2 = Math.min(a.x + a.w, b.x + b.w); if (x2 - x1 >= 2) return { orientation: "h", y, x1, x2 }; } return null; } function splitEdgeForDoor(shared, gridSize, offsetX, offsetY) { if (shared.orientation === "v") { const cells = Math.max(1, Math.round(shared.y2 - shared.y1)); const doorCell = randInt(shared.y1, shared.y2 - 1); const x = offsetX + shared.x * gridSize; const yStart = offsetY + shared.y1 * gridSize; const yDoor = offsetY + doorCell * gridSize; const yDoorEnd = yDoor + gridSize; const yEnd = offsetY + shared.y2 * gridSize; const walls = []; if (yDoor > yStart) walls.push({ x1: x, y1: yStart, x2: x, y2: yDoor }); walls.push({ x1: x, y1: yDoor, x2: x, y2: yDoorEnd, door: true }); if (yEnd > yDoorEnd) walls.push({ x1: x, y1: yDoorEnd, x2: x, y2: yEnd }); return { walls, doorMarker: { x1: x, y1: yDoor, x2: x, y2: yDoorEnd } }; } const doorCell = randInt(shared.x1, shared.x2 - 1); const y = offsetY + shared.y * gridSize; const xStart = offsetX + shared.x1 * gridSize; const xDoor = offsetX + doorCell * gridSize; const xDoorEnd = xDoor + gridSize; const xEnd = offsetX + shared.x2 * gridSize; const walls = []; if (xDoor > xStart) walls.push({ x1: xStart, y1: y, x2: xDoor, y2: y }); walls.push({ x1: xDoor, y1: y, x2: xDoorEnd, y2: y, door: true }); if (xEnd > xDoorEnd) walls.push({ x1: xDoorEnd, y1: y, x2: xEnd, y2: y }); return { walls, doorMarker: { x1: xDoor, y1: y, x2: xDoorEnd, y2: y } }; } function carveLHallway(a, b, occupancy, roomType, verticalFirst = false) { const segments = []; if (verticalFirst) { const y1 = Math.min(a.y, b.y); const y2 = Math.max(a.y, b.y); fillRectCells(occupancy, { x: a.x, y: y1, w: 1, h: y2 - y1 + 1 }, true); fillRectCells(roomType, { x: a.x, y: y1, w: 1, h: y2 - y1 + 1 }, "corridor"); segments.push({ x: a.x, y: y1, w: 1, h: y2 - y1 + 1 }); const x1 = Math.min(a.x, b.x); const x2 = Math.max(a.x, b.x); fillRectCells(occupancy, { x: x1, y: b.y, w: x2 - x1 + 1, h: 1 }, true); fillRectCells(roomType, { x: x1, y: b.y, w: x2 - x1 + 1, h: 1 }, "corridor"); segments.push({ x: x1, y: b.y, w: x2 - x1 + 1, h: 1 }); return mergeRects(segments); } const x1 = Math.min(a.x, b.x); const x2 = Math.max(a.x, b.x); fillRectCells(occupancy, { x: x1, y: a.y, w: x2 - x1 + 1, h: 1 }, true); fillRectCells(roomType, { x: x1, y: a.y, w: x2 - x1 + 1, h: 1 }, "corridor"); segments.push({ x: x1, y: a.y, w: x2 - x1 + 1, h: 1 }); const y1 = Math.min(a.y, b.y); const y2 = Math.max(a.y, b.y); fillRectCells(occupancy, { x: b.x, y: y1, w: 1, h: y2 - y1 + 1 }, true); fillRectCells(roomType, { x: b.x, y: y1, w: 1, h: y2 - y1 + 1 }, "corridor"); segments.push({ x: b.x, y: y1, w: 1, h: y2 - y1 + 1 }); return mergeRects(segments); } function mergeRects(rects) { return rects.filter((rect) => rect.w > 0 && rect.h > 0); } function rectCenterCell(rect) { return { x: rect.x + Math.floor(rect.w / 2), y: rect.y + Math.floor(rect.h / 2) }; } function fillRectCells(grid, rect, value) { for (let cy = rect.y; cy < rect.y + rect.h; cy += 1) { for (let cx = rect.x; cx < rect.x + rect.w; cx += 1) { if (grid[cy]?.[cx] !== undefined) grid[cy][cx] = value; } } } function makeGrid(width, height, value) { return Array.from({ length: height }, () => Array.from({ length: width }, () => value)); } function rectsOverlap(a, b) { return !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y); } function expandRect(rect, amount) { return { x: rect.x - amount, y: rect.y - amount, w: rect.w + amount * 2, h: rect.h + amount * 2 }; } function cellRectToPixels(rect, offsetX, offsetY, gridSize) { return { x: offsetX + rect.x * gridSize, y: offsetY + rect.y * gridSize, width: rect.w * gridSize, height: rect.h * gridSize }; } function cellEdgeToPixels(cx, cy, side, offsetX, offsetY, gridSize) { const px = offsetX + cx * gridSize; const py = offsetY + cy * gridSize; if (side === "n") return { x1: px, y1: py, x2: px + gridSize, y2: py }; if (side === "s") return { x1: px, y1: py + gridSize, x2: px + gridSize, y2: py + gridSize }; if (side === "e") return { x1: px + gridSize, y1: py, x2: px + gridSize, y2: py + gridSize }; return { x1: px, y1: py, x2: px, y2: py + gridSize }; } function edgeKey(edge) { return `${round(edge.x1)},${round(edge.y1)},${round(edge.x2)},${round(edge.y2)}`; } function dedupeEdges(edges) { const map = new Map(); for (const edge of edges) { map.set(edgeKey(edge), edge); } return Array.from(map.values()); } function randomSample(items, count) { const pool = [...items]; const out = []; while (pool.length && out.length < count) { const index = randInt(0, pool.length - 1); out.push(pool.splice(index, 1)[0]); } return out; } function randomChoice(items) { return items[randInt(0, items.length - 1)]; } function randInt(min, max) { if (max <= min) return min; return Math.floor(Math.random() * (max - min + 1)) + min; } function getLayoutPalette(terrain, kind = "building") { if (kind === "dungeon") { return { floor: terrain === "sand" ? "#c7b48c" : "#a9aaad", corridor: terrain === "sand" ? "#b9a374" : "#98999c", outline: "#1f2328", window: "#87c9ff" }; } return { floor: terrain === "grass" ? "#d3d6c8" : terrain === "sand" ? "#d9c7a1" : "#d4d5d7", corridor: terrain === "grass" ? "#c8ccb9" : "#c8c9cb", outline: "#16191d", window: "#7ec8ff" }; } function wallData(x1, y1, x2, y2) { const sense = foundry.utils.getProperty(CONST, "WALL_SENSE_TYPES.NORMAL") ?? 20; const both = foundry.utils.getProperty(CONST, "WALL_DIRECTIONS.BOTH") ?? 0; const none = foundry.utils.getProperty(CONST, "WALL_DOOR_TYPES.NONE") ?? 0; const closed = foundry.utils.getProperty(CONST, "WALL_DOOR_STATES.CLOSED") ?? 0; return { c: [round(x1), round(y1), round(x2), round(y2)], move: sense, sight: sense, sound: sense, light: sense, dir: both, door: none, ds: closed }; } function resolveGridType(preset) { const gridTypes = CONST?.GRID_TYPES ?? {}; if (preset === "hexRows") return gridTypes.HEXODDR ?? 2; if (preset === "hexColumns") return gridTypes.HEXODDQ ?? 4; return gridTypes.SQUARE ?? 1; } function buildTerrainSvg({ terrain, width, height, columns, rows, gridSize, coordinates }) { const palette = terrainPalette(terrain); const defs = terrainPatternSvg(terrain, palette); const labels = coordinates === "none" ? "" : buildCoordinateSvg({ columns, rows, gridSize, width, height, palette, mode: coordinates }); return ` `.replace(/\n+/g, " ").trim(); } function buildCoordinateSvg({ columns, rows, gridSize, width, height, palette, mode }) { const out = []; const topY = Math.max(16, gridSize * 0.22); const leftX = Math.max(12, gridSize * 0.16); const fontSize = Math.max(11, Math.round(gridSize * 0.18)); const boxFill = palette.coordBox; const textFill = palette.coordText; for (let col = 0; col < columns; col += 1) { const label = columnLabel(col); const x = col * gridSize + gridSize / 2; out.push(`