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>
257 lines
9.4 KiB
JavaScript
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";
|
|
}
|