From 7a7d328544bc2a0aa0fb28b377f806d29c70050e Mon Sep 17 00:00:00 2001 From: "Tim Evans (slaguru666)" Date: Wed, 8 Apr 2026 09:29:06 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20deploy=20=E2=80=94=20SLA=20I?= =?UTF-8?q?ndustries=20Foundry=20VTT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + lang/en.json | 8 + module.json | 33 + module/quick-battlemap-builder.mjs | 1474 ++++++++++++++++++++++++ styles/quick-battlemap-builder.css | 146 +++ templates/quick-battlemap-builder.html | 159 +++ 6 files changed, 1825 insertions(+) create mode 100644 .gitignore create mode 100644 lang/en.json create mode 100644 module.json create mode 100644 module/quick-battlemap-builder.mjs create mode 100644 styles/quick-battlemap-builder.css create mode 100644 templates/quick-battlemap-builder.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8255ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.DS_Store +._* +*.swp +worlds/ diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..07d686f --- /dev/null +++ b/lang/en.json @@ -0,0 +1,8 @@ +{ + "QBM.SceneButton": "Quick Battle Map", + "QBM.MenuName": "Quick Battle Map Builder", + "QBM.MenuHint": "Create a quick scene with terrain, grid coordinates, and procedural building or dungeon generation.", + "QBM.AppTitle": "Quick Battle Map Builder", + "QBM.ToolbarGroup": "Battle Map", + "QBM.ToolbarOpen": "Open Quick Battle Map Builder" +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..2eec5cd --- /dev/null +++ b/module.json @@ -0,0 +1,33 @@ +{ + "id": "quick-battlemap-builder", + "title": "Quick Battle Map Builder", + "description": "Create quick procedural battlemaps with square or hex grids, terrain backgrounds, random buildings, random dungeons, walls, doors, and windows.", + "version": "0.3.0", + "compatibility": { + "minimum": "13", + "verified": "13.350", + "maximum": "13" + }, + "authors": [ + { + "name": "Tim Evans", + "flags": {} + } + ], + "esmodules": [ + "module/quick-battlemap-builder.mjs" + ], + "styles": [ + "styles/quick-battlemap-builder.css" + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json", + "flags": {} + } + ], + "manifest": "https://raw.githubusercontent.com/slaguru666/quick-battlemap-builder/main/module.json", + "download": "https://github.com/slaguru666/quick-battlemap-builder/releases/latest/download/quick-battlemap-builder.zip" +} \ No newline at end of file diff --git a/module/quick-battlemap-builder.mjs b/module/quick-battlemap-builder.mjs new file mode 100644 index 0000000..c0813e6 --- /dev/null +++ b/module/quick-battlemap-builder.mjs @@ -0,0 +1,1474 @@ +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 ` + + + ${defs} + + + + ${labels} + `.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(``); + out.push(`${label}`); + } + + for (let row = 0; row < rows; row += 1) { + const label = `${row + 1}`; + const y = row * gridSize + gridSize / 2 + fontSize * 0.35; + out.push(``); + out.push(`${label}`); + } + + if (mode === "full") { + const cellFont = Math.max(9, Math.round(gridSize * 0.12)); + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < columns; col += 1) { + const label = `${columnLabel(col)}${row + 1}`; + const x = col * gridSize + 6; + const y = row * gridSize + cellFont + 4; + out.push(`${label}`); + } + } + } + + return out.join(""); +} + +function terrainPatternSvg(terrain, palette) { + if (terrain === "grass") { + return ` + + + + + + `; + } + if (terrain === "dirt") { + return ` + + + + + + `; + } + if (terrain === "metal") { + return ` + + + + + + + + `; + } + if (terrain === "sand") { + return ` + + + + `; + } + return ` + + + + + + + + + `; +} + +function terrainPalette(terrain) { + switch (terrain) { + case "grass": + return { base: "#627f53", fill: "#7fa36b", line: "#537143", detail: "#9cc285", coordBox: "rgba(13,24,11,0.55)", coordText: "#f2f8e8", coordCell: "#eff7e3", gridMinor: "rgba(20,44,15,0.18)", gridMajor: "rgba(12,28,10,0.28)" }; + case "dirt": + return { base: "#6f5438", fill: "#876547", line: "#4f3923", detail: "#a27c57", coordBox: "rgba(28,15,8,0.55)", coordText: "#f7efe5", coordCell: "#f4e8da", gridMinor: "rgba(35,20,10,0.18)", gridMajor: "rgba(25,14,7,0.28)" }; + case "metal": + return { base: "#475760", fill: "#5d6f78", line: "#2d3940", detail: "#b9c7cf", coordBox: "rgba(8,14,18,0.58)", coordText: "#edf4f7", coordCell: "#e2edf1", gridMinor: "rgba(14,19,24,0.18)", gridMajor: "rgba(9,12,15,0.3)" }; + case "sand": + return { base: "#c7ac72", fill: "#d6bc84", line: "#a58850", detail: "#edd39d", coordBox: "rgba(53,39,14,0.45)", coordText: "#fff9ec", coordCell: "#fff4d1", gridMinor: "rgba(77,58,19,0.16)", gridMajor: "rgba(55,40,12,0.26)" }; + case "concrete": + default: + return { base: "#6d7174", fill: "#858a8e", line: "#4b5054", detail: "#b7bbbe", coordBox: "rgba(20,22,24,0.55)", coordText: "#f3f5f6", coordCell: "#f3f5f6", gridMinor: "rgba(26,29,31,0.18)", gridMajor: "rgba(16,18,20,0.28)" }; + } +} + +function terrainBaseColor(terrain) { + return terrainPalette(terrain).base; +} + +async function ensureSceneFolder(name) { + let folder = game.folders?.find((entry) => entry.type === "Scene" && entry.name === name && !entry.folder); + if (!folder) { + folder = await Folder.create({ name, type: "Scene", color: "#40637a" }); + } + return folder; +} + +function resolveScene(sceneId) { + return game.scenes?.get(sceneId) ?? canvas.scene ?? null; +} + +function getFormData(form) { + const FormDataCtor = foundry.applications?.ux?.FormDataExtended ?? globalThis.FormDataExtended; + return new FormDataCtor(form).object; +} + +async function createSceneBackgroundAsset({ name, terrain, width, height, columns, rows, gridSize, coordinates }) { + const worldId = game.world?.id; + if (!worldId) return null; + + const targetDir = `worlds/${worldId}/${QBM_ASSET_DIR}`; + const svg = buildTerrainSvg({ terrain, width, height, columns, rows, gridSize, coordinates }); + const safeName = slugify(name || "battle-map"); + const filename = `${safeName}-${Date.now()}.svg`; + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + + try { + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([svg], filename, { type: "image/svg+xml" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + return response?.path ?? `${targetDir}/${filename}`; + } catch (error) { + console.warn(`${MODULE_ID} | Failed to upload generated background asset`, error); + ui.notifications.warn("Battle map background could not be generated. Creating a plain scene instead."); + return null; + } +} + +function buildSceneName() { + const stamp = new Date().toISOString().slice(0, 16).replace("T", " "); + return `Battle Map ${stamp}`; +} + +function slugify(value) { + return String(value ?? "battle-map") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "battle-map"; +} + +function columnLabel(index) { + let value = Number(index ?? 0); + let out = ""; + do { + out = String.fromCharCode(65 + (value % 26)) + out; + value = Math.floor(value / 26) - 1; + } while (value >= 0); + return out; +} + +function clampInt(value, min, max, fallback) { + const numeric = Number(value ?? fallback); + if (!Number.isFinite(numeric)) return fallback; + return Math.max(min, Math.min(max, Math.round(numeric))); +} + +function normalizeChoice(value, fallback, allowed) { + return allowed.includes(value) ? value : fallback; +} + +function round(value) { + return Math.round((Number(value ?? 0) || 0) * 100) / 100; +} diff --git a/styles/quick-battlemap-builder.css b/styles/quick-battlemap-builder.css new file mode 100644 index 0000000..d2ba75c --- /dev/null +++ b/styles/quick-battlemap-builder.css @@ -0,0 +1,146 @@ +.quick-battlemap-builder .window-content, +.qbm-app { + background: linear-gradient(180deg, #10161c 0%, #17212a 100%); + color: #edf1f4; +} + +.qbm-app { + display: grid; + gap: 14px; + padding: 8px; +} + +.qbm-section { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 12px; +} + +.qbm-section h2 { + margin: 0 0 10px; + font-size: 1.05rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.qbm-grid { + display: grid; + gap: 10px; +} + +.qbm-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.qbm-grid label, +.qbm-checks label { + display: grid; + gap: 4px; +} + +.qbm-grid label span { + font-size: 0.85rem; + color: rgba(237, 241, 244, 0.82); +} + +.qbm-grid input, +.qbm-grid select { + width: 100%; + color: #f8fafc; + background: rgba(8, 12, 17, 0.88); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 8px; + padding: 6px 8px; +} + +.qbm-checks { + display: grid; + gap: 6px; + margin-top: 10px; +} + +.qbm-checks input { + margin-right: 6px; +} + +.qbm-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.qbm-actions button { + color: #f8fafc; + background: linear-gradient(180deg, #29455a 0%, #1c2f3e 100%); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 10px; + padding: 8px 12px; +} + +.qbm-actions button:hover, +.qbm-actions button:focus { + box-shadow: 0 0 0 1px rgba(240, 194, 75, 0.45); +} + +.qbm-note { + margin: 10px 0 0; + font-size: 0.83rem; + color: rgba(237, 241, 244, 0.78); +} + +.qbm-note-block { + display: grid; + gap: 4px; + align-content: start; + padding: 10px 12px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); +} + +.qbm-note-block strong { + font-size: 0.9rem; +} + +.qbm-note-block span { + font-size: 0.82rem; + line-height: 1.35; + color: rgba(237, 241, 244, 0.8); +} + +.qbm-preset-list { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.qbm-preset-card { + display: grid; + gap: 4px; + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; + padding: 10px; +} + +.qbm-preset-card strong { + font-size: 0.9rem; +} + +.qbm-preset-card span { + font-size: 0.8rem; + color: rgba(237, 241, 244, 0.78); + line-height: 1.3; +} + +.qbm-scene-directory-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.qbm-scene-directory-actions button { + flex: 1 1 auto; + min-width: 160px; +} diff --git a/templates/quick-battlemap-builder.html b/templates/quick-battlemap-builder.html new file mode 100644 index 0000000..e89a606 --- /dev/null +++ b/templates/quick-battlemap-builder.html @@ -0,0 +1,159 @@ +
+
+

New Scene

+
+ + + + + + + + + + + + +
+
+ + + + +
+
+ +
+
+ +
+

Layout Generator

+
+ + + + + + +
+
+ + + + +
+

Use this for random buildings with rooms, doors, and windows, or dungeons with rooms, corridors, and door openings. Quick Stamps remain as simple manual tools.

+
+ +
+

Legacy Manual Shapes

+
+ +
+ Quick Use + These add a visible outline drawing and matching walls together, so buildings and dungeon shells are immediately playable. +
+
+
+ +
+

These older shell presets stay here as a fallback. The new generator above is the recommended path for buildings and dungeons.

+
+ +
+

Presets

+
+ {{#each presetNotes as |note|}} +
+ {{note.title}} + {{note.copy}} +
+ {{/each}} +
+
+