Files
slaguru666andClaude Opus 4.8 100e514c4d 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>
2026-06-17 15:05:25 +01:00

257 lines
9.4 KiB
JavaScript

/**
* 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";
}