1475 lines
50 KiB
JavaScript
1475 lines
50 KiB
JavaScript
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 type="button" class="qbm-launch">
|
||
<i class="fas fa-map"></i> ${game.i18n.localize("QBM.SceneButton")}
|
||
</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 = $('<div class="qbm-scene-directory-actions"></div>');
|
||
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 = $('<div class="qbm-scene-directory-actions"></div>');
|
||
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 `
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||
<defs>
|
||
${defs}
|
||
</defs>
|
||
<rect width="${width}" height="${height}" fill="${palette.base}" />
|
||
<rect width="${width}" height="${height}" fill="url(#terrain-pattern)" />
|
||
${labels}
|
||
</svg>`.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(`<rect x="${round(x - gridSize * 0.28)}" y="${round(topY - fontSize)}" width="${round(gridSize * 0.56)}" height="${round(fontSize * 1.35)}" rx="6" fill="${boxFill}" />`);
|
||
out.push(`<text x="${round(x)}" y="${round(topY)}" text-anchor="middle" font-size="${fontSize}" font-family="Arial, sans-serif" fill="${textFill}">${label}</text>`);
|
||
}
|
||
|
||
for (let row = 0; row < rows; row += 1) {
|
||
const label = `${row + 1}`;
|
||
const y = row * gridSize + gridSize / 2 + fontSize * 0.35;
|
||
out.push(`<rect x="2" y="${round(y - fontSize)}" width="${round(Math.max(22, gridSize * 0.42))}" height="${round(fontSize * 1.35)}" rx="6" fill="${boxFill}" />`);
|
||
out.push(`<text x="${leftX}" y="${round(y)}" text-anchor="middle" font-size="${fontSize}" font-family="Arial, sans-serif" fill="${textFill}">${label}</text>`);
|
||
}
|
||
|
||
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(`<text x="${round(x)}" y="${round(y)}" font-size="${cellFont}" font-family="Arial, sans-serif" fill="${palette.coordCell}" opacity="0.45">${label}</text>`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return out.join("");
|
||
}
|
||
|
||
function terrainPatternSvg(terrain, palette) {
|
||
if (terrain === "grass") {
|
||
return `
|
||
<pattern id="terrain-pattern" width="140" height="140" patternUnits="userSpaceOnUse">
|
||
<rect width="140" height="140" fill="${palette.fill}" />
|
||
<path d="M14 126 Q20 84 28 120 M38 130 Q46 80 56 124 M82 135 Q88 96 98 130 M114 128 Q122 88 128 122" stroke="${palette.line}" stroke-width="4" stroke-linecap="round" fill="none" opacity="0.7" />
|
||
<circle cx="42" cy="32" r="10" fill="${palette.detail}" opacity="0.22" />
|
||
<circle cx="104" cy="54" r="12" fill="${palette.detail}" opacity="0.18" />
|
||
</pattern>`;
|
||
}
|
||
if (terrain === "dirt") {
|
||
return `
|
||
<pattern id="terrain-pattern" width="160" height="160" patternUnits="userSpaceOnUse">
|
||
<rect width="160" height="160" fill="${palette.fill}" />
|
||
<ellipse cx="38" cy="48" rx="24" ry="12" fill="${palette.detail}" opacity="0.15" />
|
||
<ellipse cx="120" cy="96" rx="30" ry="14" fill="${palette.detail}" opacity="0.18" />
|
||
<path d="M0 66 C36 60 52 82 92 76 S144 54 160 72" stroke="${palette.line}" stroke-width="6" opacity="0.18" fill="none" />
|
||
</pattern>`;
|
||
}
|
||
if (terrain === "metal") {
|
||
return `
|
||
<pattern id="terrain-pattern" width="180" height="180" patternUnits="userSpaceOnUse">
|
||
<rect width="180" height="180" fill="${palette.fill}" />
|
||
<path d="M0 60 H180 M0 120 H180 M60 0 V180 M120 0 V180" stroke="${palette.line}" stroke-width="4" opacity="0.35" />
|
||
<circle cx="18" cy="18" r="4" fill="${palette.detail}" />
|
||
<circle cx="162" cy="18" r="4" fill="${palette.detail}" />
|
||
<circle cx="18" cy="162" r="4" fill="${palette.detail}" />
|
||
<circle cx="162" cy="162" r="4" fill="${palette.detail}" />
|
||
</pattern>`;
|
||
}
|
||
if (terrain === "sand") {
|
||
return `
|
||
<pattern id="terrain-pattern" width="180" height="180" patternUnits="userSpaceOnUse">
|
||
<rect width="180" height="180" fill="${palette.fill}" />
|
||
<path d="M0 40 C30 24 58 24 90 40 S150 56 180 40 M0 100 C26 88 58 84 90 100 S148 116 180 100 M0 150 C24 138 60 132 90 150 S146 166 180 150" stroke="${palette.line}" stroke-width="5" opacity="0.22" fill="none" />
|
||
</pattern>`;
|
||
}
|
||
return `
|
||
<pattern id="terrain-pattern" width="180" height="180" patternUnits="userSpaceOnUse">
|
||
<rect width="180" height="180" fill="${palette.fill}" />
|
||
<path d="M0 0 H180 V180 H0 Z" stroke="${palette.line}" stroke-width="2" opacity="0.15" fill="none" />
|
||
<path d="M0 90 H180 M90 0 V180" stroke="${palette.line}" stroke-width="5" opacity="0.18" />
|
||
<circle cx="36" cy="36" r="4" fill="${palette.detail}" opacity="0.6" />
|
||
<circle cx="144" cy="36" r="4" fill="${palette.detail}" opacity="0.6" />
|
||
<circle cx="36" cy="144" r="4" fill="${palette.detail}" opacity="0.6" />
|
||
<circle cx="144" cy="144" r="4" fill="${palette.detail}" opacity="0.6" />
|
||
</pattern>`;
|
||
}
|
||
|
||
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;
|
||
}
|