Mapwright v0.5.0 — procedural battle map generator for Foundry VTT
Buildings (modern/fantasy, multi-floor, footprint shapes), caves, outdoor biomes, and town/village + city-block settlements. Auto-places Foundry walls, doors, windows, lighting; furniture as locked tiles; live preview. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Foundry-facing scene assembly (generic across all generators).
|
||||
*
|
||||
* Takes a uniform MapResult (svg + wall segments + notes + lights) and produces
|
||||
* a real Foundry scene with a raster background. Everything is flagged for
|
||||
* later cleanup. Uses Foundry globals — browser only.
|
||||
*
|
||||
* MapResult shape:
|
||||
* { width, height, gridSize, backgroundColor, segments, svg, notes, lights }
|
||||
*/
|
||||
|
||||
import { PIECES, pieceSpriteSVG } from "./furniture.mjs";
|
||||
|
||||
const MODULE_ID = "mapwright";
|
||||
const SPRITE_PPC = 128; // sprite resolution per grid cell
|
||||
const _spriteCache = new Set(); // paths known to exist this session
|
||||
|
||||
const C = () => ({
|
||||
MOVE_NORMAL: CONST.WALL_MOVEMENT_TYPES?.NORMAL ?? 20,
|
||||
SENSE_NONE: CONST.WALL_SENSE_TYPES?.NONE ?? 0,
|
||||
SENSE_NORMAL: CONST.WALL_SENSE_TYPES?.NORMAL ?? 20,
|
||||
DOOR_NONE: CONST.WALL_DOOR_TYPES?.NONE ?? 0,
|
||||
DOOR_BASIC: CONST.WALL_DOOR_TYPES?.DOOR ?? 1,
|
||||
DS_CLOSED: CONST.WALL_DOOR_STATES?.CLOSED ?? 0,
|
||||
DS_LOCKED: CONST.WALL_DOOR_STATES?.LOCKED ?? 2,
|
||||
GRID_SQUARE: CONST.GRID_TYPES?.SQUARE ?? 1
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {object} result MapResult
|
||||
* @param {object} meta { name, gridSize, seed, subtype, tokenVision }
|
||||
* @returns {Promise<Scene>}
|
||||
*/
|
||||
export async function createScene(result, meta) {
|
||||
const k = C();
|
||||
const gs = result.gridSize ?? meta.gridSize ?? 70;
|
||||
const W = result.width;
|
||||
const H = result.height;
|
||||
|
||||
const filename = `${slug(meta.name)}-${Date.now()}.png`;
|
||||
const bgPath = await rasterAndUpload(result.svg, W, H, filename);
|
||||
|
||||
const bgColor = result.backgroundColor ?? "#000000";
|
||||
const sceneData = {
|
||||
name: meta.name,
|
||||
width: W,
|
||||
height: H,
|
||||
padding: 0.0,
|
||||
grid: { type: k.GRID_SQUARE, size: gs },
|
||||
// Default: a flat, fully-visible battlemap that matches the preview.
|
||||
// Dynamic lighting (token vision + ambient lights) is opt-in.
|
||||
tokenVision: meta.tokenVision ?? false,
|
||||
flags: { [MODULE_ID]: { generated: true, seed: meta.seed, subtype: meta.subtype } }
|
||||
};
|
||||
if (meta.folderId) sceneData.folder = meta.folderId;
|
||||
const scene = await Scene.create(sceneData);
|
||||
|
||||
await applyBackground(scene, bgPath, bgColor);
|
||||
|
||||
const wallDocs = (result.segments ?? []).map((s) => segmentToWall(s, gs, k));
|
||||
if (wallDocs.length) await scene.createEmbeddedDocuments("Wall", wallDocs);
|
||||
|
||||
if (result.lights?.length) await scene.createEmbeddedDocuments("AmbientLight", result.lights);
|
||||
if (result.notes?.length) await scene.createEmbeddedDocuments("Note", result.notes);
|
||||
if (result.placements?.length) await createFurnitureTiles(scene, result.placements, meta.subtype);
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multi-floor building as a folder of linked scenes.
|
||||
* @param {object[]} results one MapResult per floor (each may carry floorName)
|
||||
* @param {object} meta { name, gridSize, seed, subtype }
|
||||
* @returns {Promise<Scene[]>}
|
||||
*/
|
||||
export async function createSceneSet(results, meta) {
|
||||
const folder = await Folder.create({ name: meta.name, type: "Scene" });
|
||||
const scenes = [];
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
const scene = await createScene(r, {
|
||||
...meta,
|
||||
name: `${meta.name} — ${r.floorName ?? `Floor ${i + 1}`}`,
|
||||
folderId: folder.id
|
||||
});
|
||||
scenes.push(scene);
|
||||
}
|
||||
if (scenes[0]) await scenes[0].view?.();
|
||||
return scenes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the background image to a scene, handling the v14 Levels architecture.
|
||||
*
|
||||
* In Foundry v14 `Scene#background` is deprecated — the image lives on the
|
||||
* scene's default Level (`scene.levels` → `level.background.src`). On v12/v13
|
||||
* the image is the scene's own `background.src`. This sets whichever applies.
|
||||
*/
|
||||
async function applyBackground(scene, src, color) {
|
||||
if (!scene) return false;
|
||||
|
||||
// v14+: background lives on the scene's (auto-created) default Level.
|
||||
const levels = scene.levels;
|
||||
if (levels && (levels.size ?? levels.length)) {
|
||||
const first = levels.get?.(scene.initialLevel?._id ?? scene.initialLevel) ?? [...levels][0];
|
||||
if (first) {
|
||||
await scene.updateEmbeddedDocuments("Level", [{ _id: first.id, background: { src, color } }]);
|
||||
const ok = scene.levels.get(first.id)?.background?.src === src;
|
||||
if (ok) console.log(`Mapwright: scene "${scene.name}" background set on level → ${src}`);
|
||||
else console.warn(`Mapwright: level background did not apply (wanted "${src}").`);
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
// v12/v13 fallback: background on the scene document itself.
|
||||
await scene.update({ background: { src, color } });
|
||||
const ok = scene.background?.src === src;
|
||||
if (!ok) {
|
||||
console.warn(`Mapwright: scene background did not apply (got "${scene.background?.src}", wanted "${src}").`);
|
||||
ui.notifications?.warn?.("Mapwright: map image uploaded but the scene background didn't apply — see console (F12).");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function segmentToWall(s, gs, k) {
|
||||
const c = [
|
||||
Math.round(s.x1 * gs), Math.round(s.y1 * gs),
|
||||
Math.round(s.x2 * gs), Math.round(s.y2 * gs)
|
||||
];
|
||||
const base = {
|
||||
c,
|
||||
move: k.MOVE_NORMAL,
|
||||
sight: k.SENSE_NORMAL,
|
||||
sound: k.SENSE_NORMAL,
|
||||
light: k.SENSE_NORMAL,
|
||||
flags: { [MODULE_ID]: { generated: true, kind: s.kind } }
|
||||
};
|
||||
if (s.kind === "door") {
|
||||
base.door = k.DOOR_BASIC;
|
||||
base.ds = s.locked ? k.DS_LOCKED : k.DS_CLOSED;
|
||||
} else if (s.kind === "window") {
|
||||
base.sight = k.SENSE_NONE;
|
||||
base.light = k.SENSE_NONE;
|
||||
} else if (s.kind === "water") {
|
||||
// Deep water: blocks movement only; sight/sound/light pass freely.
|
||||
base.sight = k.SENSE_NONE;
|
||||
base.sound = k.SENSE_NONE;
|
||||
base.light = k.SENSE_NONE;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function filePicker() {
|
||||
return foundry.applications?.apps?.FilePicker?.implementation
|
||||
?? foundry.applications?.apps?.FilePicker
|
||||
?? FilePicker;
|
||||
}
|
||||
|
||||
/** Normalise an upload path to a clean relative form Foundry will accept. */
|
||||
function cleanPath(p) {
|
||||
return String(p ?? "")
|
||||
.replace(/^https?:\/\/[^/]+/i, "")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
/** Rasterise an SVG string to a PNG Blob (transparent where the SVG is). */
|
||||
async function svgToPngBlob(svg, w, h) {
|
||||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = new Image();
|
||||
img.width = w; img.height = h;
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error("SVG rasterisation failed"));
|
||||
img.src = url;
|
||||
});
|
||||
try { if (img.decode) await img.decode(); } catch (e) { /* best-effort */ }
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
|
||||
const png = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!png) throw new Error("Mapwright: canvas.toBlob returned null.");
|
||||
return png;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBlob(blob, dir, filename) {
|
||||
const FP = filePicker();
|
||||
try { await FP.createDirectory("data", dir); } catch (e) { /* exists */ }
|
||||
const file = new File([blob], filename, { type: "image/png" });
|
||||
const upload = await FP.upload("data", dir, file, {}, { notify: false });
|
||||
if (!upload?.path) throw new Error("Mapwright: upload failed (no path). Check file-upload permission.");
|
||||
return cleanPath(upload.path);
|
||||
}
|
||||
|
||||
async function rasterAndUpload(svg, w, h, filename) {
|
||||
const png = await svgToPngBlob(svg, w, h);
|
||||
const path = await uploadBlob(png, MODULE_ID, filename);
|
||||
console.log(`Mapwright: background uploaded → "${path}" (${Math.round(png.size / 1024)} KB)`);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Ensure a furniture sprite PNG exists; returns its served path. Cached. */
|
||||
async function ensureSprite(kind, subtype) {
|
||||
const filename = `${subtype}-${kind}.png`;
|
||||
const path = `${MODULE_ID}/furniture/${filename}`;
|
||||
if (_spriteCache.has(path)) return path;
|
||||
// Check if already uploaded (persists across sessions).
|
||||
try {
|
||||
const listing = await filePicker().browse("data", `${MODULE_ID}/furniture`);
|
||||
if (listing?.files?.some((f) => cleanPath(f).endsWith(filename))) { _spriteCache.add(path); return path; }
|
||||
} catch (e) { /* dir may not exist yet */ }
|
||||
const def = PIECES[kind];
|
||||
if (!def) return null;
|
||||
const [pw, ph] = def.footprint;
|
||||
const w = pw * SPRITE_PPC, h = ph * SPRITE_PPC;
|
||||
const png = await svgToPngBlob(pieceSpriteSVG(kind, subtype, w, h), w, h);
|
||||
const uploaded = await uploadBlob(png, `${MODULE_ID}/furniture`, filename);
|
||||
_spriteCache.add(uploaded);
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
/** Create locked furniture Tiles from placements. */
|
||||
async function createFurnitureTiles(scene, placements, subtype) {
|
||||
if (!placements?.length) return;
|
||||
// Resolve sprite paths (unique per kind).
|
||||
const paths = {};
|
||||
for (const kind of new Set(placements.map((p) => p.kind))) {
|
||||
try { paths[kind] = await ensureSprite(kind, subtype); } catch (e) { console.warn(`Mapwright: sprite for "${kind}" failed`, e); }
|
||||
}
|
||||
const tiles = [];
|
||||
for (const p of placements) {
|
||||
const src = paths[p.kind];
|
||||
if (!src) continue;
|
||||
tiles.push({
|
||||
texture: { src },
|
||||
x: Math.round(p.cx - p.wpx / 2),
|
||||
y: Math.round(p.cy - p.hpx / 2),
|
||||
width: Math.round(p.wpx),
|
||||
height: Math.round(p.hpx),
|
||||
rotation: p.rotation ?? 0,
|
||||
locked: true,
|
||||
sort: 0,
|
||||
flags: { [MODULE_ID]: { generated: true, kind: p.kind } }
|
||||
});
|
||||
}
|
||||
if (tiles.length) await scene.createEmbeddedDocuments("Tile", tiles);
|
||||
}
|
||||
|
||||
function slug(str) {
|
||||
return String(str || "map").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "map";
|
||||
}
|
||||
Reference in New Issue
Block a user