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