From 100e514c4deacb7d37da6dc03f38d08d81237be4 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:05:25 +0100 Subject: [PATCH] =?UTF-8?q?Mapwright=20v0.5.0=20=E2=80=94=20procedural=20b?= =?UTF-8?q?attle=20map=20generator=20for=20Foundry=20VTT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 11 + README.md | 75 +++++++ dev/make-raster-test.mjs | 65 ++++++ dev/render-cave-sample.mjs | 24 +++ dev/render-outdoor-sample.mjs | 24 +++ dev/render-sample.mjs | 28 +++ dev/render-settlement-sample.mjs | 22 ++ lang/en.json | 5 + module.json | 34 +++ module/app/generator-app.mjs | 314 +++++++++++++++++++++++++++ module/lib/building.mjs | 293 +++++++++++++++++++++++++ module/lib/dungeon.mjs | 114 ++++++++++ module/lib/furniture-layout.mjs | 91 ++++++++ module/lib/furniture.mjs | 150 +++++++++++++ module/lib/organic.mjs | 261 +++++++++++++++++++++++ module/lib/outdoor.mjs | 112 ++++++++++ module/lib/population.mjs | 75 +++++++ module/lib/render-cave.mjs | 134 ++++++++++++ module/lib/render-outdoor.mjs | 155 ++++++++++++++ module/lib/render-settlement.mjs | 267 +++++++++++++++++++++++ module/lib/render-vector.mjs | 354 +++++++++++++++++++++++++++++++ module/lib/rng.mjs | 88 ++++++++ module/lib/scene.mjs | 256 ++++++++++++++++++++++ module/lib/settlement.mjs | 300 ++++++++++++++++++++++++++ module/lib/walls.mjs | 274 ++++++++++++++++++++++++ module/mapwright.mjs | 61 ++++++ styles/mapwright.css | 109 ++++++++++ templates/generator.html | 75 +++++++ 28 files changed, 3771 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 dev/make-raster-test.mjs create mode 100644 dev/render-cave-sample.mjs create mode 100644 dev/render-outdoor-sample.mjs create mode 100644 dev/render-sample.mjs create mode 100644 dev/render-settlement-sample.mjs create mode 100644 lang/en.json create mode 100644 module.json create mode 100644 module/app/generator-app.mjs create mode 100644 module/lib/building.mjs create mode 100644 module/lib/dungeon.mjs create mode 100644 module/lib/furniture-layout.mjs create mode 100644 module/lib/furniture.mjs create mode 100644 module/lib/organic.mjs create mode 100644 module/lib/outdoor.mjs create mode 100644 module/lib/population.mjs create mode 100644 module/lib/render-cave.mjs create mode 100644 module/lib/render-outdoor.mjs create mode 100644 module/lib/render-settlement.mjs create mode 100644 module/lib/render-vector.mjs create mode 100644 module/lib/rng.mjs create mode 100644 module/lib/scene.mjs create mode 100644 module/lib/settlement.mjs create mode 100644 module/lib/walls.mjs create mode 100644 module/mapwright.mjs create mode 100644 styles/mapwright.css create mode 100644 templates/generator.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcd2fcc --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Dev render outputs (kept local; harness scripts in dev/*.mjs stay) +dev/*.svg +dev/*.png +dev/*.jpeg +dev/*.html + +# Tooling / OS +.DS_Store +.playwright-mcp/ +*.log +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9b7c0b --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Mapwright — Battle Map Generator (Foundry VTT) + +One-click procedural battle maps for **any** Foundry world. Generates good-looking +floor plans with Foundry **walls, doors, windows and lighting already placed**, so a +map is playable the moment it's created. Built to be usable with no prior experience. + +## Status — v0.4.0 + +Six map categories. New since v0.3: +- **Town / Village** and **City Block** — top-down tactical maps where buildings + are solid rooftop blocks (perimeter walls block tokens; streets are the open, + token-scaled play space). City = street grid + sidewalks + lane markings; + Village = curved road, scattered houses, gardens, trees, optional pond. +- **Furniture is now locked Tiles** (not painted into the background): room-aware + placement (grid-aligned, against walls, doorways clear), players can't move + them, sprites auto-generated + cached. +- Backgrounds set via the v14 **Level** system; flat/fully-lit by default with + dynamic lighting opt-in. + +### Earlier — v0.3.0 + +Three generator families, all standalone, all auto-placing Foundry walls/doors/lights: + +**Buildings** (Modern / Fantasy) +- **Non-rectangular footprints**: Rectangle / L / T / U / Cross. +- **Multi-floor** (1–4): a folder of linked scenes sharing one footprint, with a + vertical-circulation column — **elevator** (modern) or **staircase** (fantasy) — + aligned at the same spot on every floor, plus up/down GM notes. +- **Era-aware door & window artwork**: planked wood doors / metal-slab glass doors + with handles + swing arcs; framed, mullioned windows. The Foundry door/window + walls are placed to match the art exactly. +- **Furniture / props** drawn per room type (beds, desks, meeting tables, server + racks, counters, crates, shelves…). System-agnostic, vector, no external art. + +**Caves / Dungeons** (Rough Cave / Crypt / Sewer) +- Organic cellular-automata caverns — flowing, non-axis-aligned walls (contour + traced, smoothed, simplified to diagonal Foundry wall segments). Rock floor, + cast shadows, scattered debris, labelled features + torch lighting. + +**Outdoors** (Grassland / Desert / Snow / Badlands) +- Open terrain with organic water bodies (shoreline blocks movement, not sight), + boulder cover, clustered trees, biome ground texture. + +Shared: **live WYSIWYG preview**, seeded reproducibility (🎲 reroll), Foundry +Walls / Doors / Windows / AmbientLights / Notes — works in any world. + +Planned next: prop/furniture as interactive Tiles, built-dungeon (rooms+corridors) +style, water/elevation in caves, UVTT export. + +## Architecture + +One geometry model drives **both** the picture and the playable walls, so they can +never disagree (the failing the older generators had): + +``` +seed → building.mjs (BSP → rooms + region grid) + → walls.mjs (region grid → merged wall/door/window segments + connectivity) + ├─ render-vector.mjs → SVG (preview + scene background) + └─ scene.mjs → Foundry Walls/Doors/Lights/Notes +``` + +`module/lib/*` is pure and Foundry-agnostic (unit-testable in Node). +`module/app/*` is the dialog. `module/mapwright.mjs` wires the toolbar button. + +## Try it + +1. Enable **Mapwright** in *Manage Modules* (it's standalone — no dependencies). +2. Open the **Walls** tool group in the scene controls → click the **map** icon + (or run `game.mapwright.open()` in the console, or use the Scenes tab button). +3. Pick a type/size, watch the preview, hit **Create Map**. + +## Dev + +`node dev/render-sample.mjs` renders sample SVGs (no Foundry needed) for eyeballing +visual quality. On macOS, `qlmanage -t -s 1600 -o . *.svg` rasterises them to PNG. diff --git a/dev/make-raster-test.mjs b/dev/make-raster-test.mjs new file mode 100644 index 0000000..0db5400 --- /dev/null +++ b/dev/make-raster-test.mjs @@ -0,0 +1,65 @@ +/** + * Build an HTML harness that renders one generated SVG two ways: + * left = inline in the DOM (what the preview does — lenient HTML parser) + * right = rasterised to a via Image (what scene.mjs does) + * Used with Playwright to reproduce the "missing content in scene" bug. + */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { Rng } from "../module/lib/rng.mjs"; +import { generateBuilding } from "../module/lib/building.mjs"; +import { buildWalls } from "../module/lib/walls.mjs"; +import { renderSVG } from "../module/lib/render-vector.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const layout = generateBuilding({ cols: 26, rows: 18, targetRooms: 11, subtype: "modern", seed: "mire-680", shape: "rectangle" }); +const walls = buildWalls(layout, { rng: new Rng("mire-680::walls::0") }); +const svg = renderSVG({ layout, walls, gridSize: 100, subtype: "modern", seed: "mire-680::0" }); +const W = layout.cols * 100, H = layout.rows * 100; + +const html = ` +
+

A: inline SVG (preview)

${svg}
+

B: SVG→canvas

+

C: →PNG blob→reload (final scene bytes)

+
+ +`; + +writeFileSync(join(here, "raster-test.html"), html); +console.log("wrote", join(here, "raster-test.html"), "size", svg.length); diff --git a/dev/render-cave-sample.mjs b/dev/render-cave-sample.mjs new file mode 100644 index 0000000..9a28397 --- /dev/null +++ b/dev/render-cave-sample.mjs @@ -0,0 +1,24 @@ +/** Dev harness for the cave engine. node dev/render-cave-sample.mjs */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { generateCave } from "../module/lib/dungeon.mjs"; +import { renderCaveSVG } from "../module/lib/render-cave.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const SAMPLES = [ + { name: "cave-stone", theme: "cave", cols: 30, rows: 22, seed: "deep-4" }, + { name: "cave-crypt", theme: "crypt", cols: 28, rows: 20, seed: "tomb-7" }, + { name: "cave-sewer", theme: "sewer", cols: 26, rows: 20, seed: "drain-2" } +]; + +for (const cfg of SAMPLES) { + const model = generateCave({ cols: cfg.cols, rows: cfg.rows, seed: cfg.seed }); + const svg = renderCaveSVG({ model, gridSize: 70, theme: cfg.theme, seed: cfg.seed }); + const segCount = model.segments.length; + console.log(`${cfg.name.padEnd(12)} loops:${model.loops.length} segments:${segCount} features:${model.features.length}`); + writeFileSync(join(here, `${cfg.name}.svg`), svg); +} +console.log("\nWrote cave samples to", here); diff --git a/dev/render-outdoor-sample.mjs b/dev/render-outdoor-sample.mjs new file mode 100644 index 0000000..6cf21e0 --- /dev/null +++ b/dev/render-outdoor-sample.mjs @@ -0,0 +1,24 @@ +/** Dev harness for the outdoor engine. node dev/render-outdoor-sample.mjs */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { generateOutdoor } from "../module/lib/outdoor.mjs"; +import { renderOutdoorSVG } from "../module/lib/render-outdoor.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const SAMPLES = [ + { name: "out-grass", biome: "grassland", cols: 28, rows: 20, seed: "meadow-5" }, + { name: "out-desert", biome: "desert", cols: 28, rows: 20, seed: "dune-3" }, + { name: "out-snow", biome: "snow", cols: 26, rows: 20, seed: "frost-8" }, + { name: "out-badlands", biome: "badlands", cols: 30, rows: 20, seed: "mesa-2" } +]; + +for (const cfg of SAMPLES) { + const model = generateOutdoor({ cols: cfg.cols, rows: cfg.rows, biome: cfg.biome, seed: cfg.seed }); + const svg = renderOutdoorSVG({ model, gridSize: 70, seed: cfg.seed }); + console.log(`${cfg.name.padEnd(14)} trees:${model.trees.length} rocks:${model.rocks.length} water:${model.water ? "y" : "n"} segs:${model.segments.length}`); + writeFileSync(join(here, `${cfg.name}.svg`), svg); +} +console.log("\nWrote outdoor samples to", here); diff --git a/dev/render-sample.mjs b/dev/render-sample.mjs new file mode 100644 index 0000000..03ea52c --- /dev/null +++ b/dev/render-sample.mjs @@ -0,0 +1,28 @@ +/** Dev harness — building engine + furniture placement. node dev/render-sample.mjs */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { Rng } from "../module/lib/rng.mjs"; +import { generateBuilding } from "../module/lib/building.mjs"; +import { buildWalls } from "../module/lib/walls.mjs"; +import { computePlacements } from "../module/lib/furniture-layout.mjs"; +import { renderSVG } from "../module/lib/render-vector.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const SAMPLES = [ + { name: "modern-rect", subtype: "modern", shape: "rectangle", cols: 26, rows: 18, targetRooms: 11, seed: "office-7" }, + { name: "fantasy-u", subtype: "fantasy", shape: "u", cols: 28, rows: 22, targetRooms: 12, seed: "keep-3" }, + { name: "modern-plus", subtype: "modern", shape: "plus", cols: 30, rows: 24, targetRooms: 14, seed: "hub-2" } +]; + +for (const cfg of SAMPLES) { + const layout = generateBuilding(cfg); + const walls = buildWalls(layout, { rng: new Rng(`${cfg.seed}::walls`) }); + const placements = computePlacements(layout, walls, 70, cfg.subtype, cfg.seed); + const svg = renderSVG({ layout, walls, gridSize: 70, subtype: cfg.subtype, seed: cfg.seed, placements, showFurniture: true }); + console.log(`${cfg.name.padEnd(14)} rooms:${layout.rooms.length} furniture:${placements.length} doors:${walls.doors.length}`); + writeFileSync(join(here, `${cfg.name}.svg`), svg); +} +console.log("\nWrote building samples to", here); diff --git a/dev/render-settlement-sample.mjs b/dev/render-settlement-sample.mjs new file mode 100644 index 0000000..ac90b51 --- /dev/null +++ b/dev/render-settlement-sample.mjs @@ -0,0 +1,22 @@ +/** Dev harness for the settlement engine. node dev/render-settlement-sample.mjs */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { generateSettlement } from "../module/lib/settlement.mjs"; +import { renderSettlementSVG } from "../module/lib/render-settlement.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const SAMPLES = [ + { name: "city-block", style: "city", cols: 34, rows: 24, seed: "downtown-5" }, + { name: "village", style: "village", cols: 34, rows: 24, seed: "hamlet-3" } +]; + +for (const cfg of SAMPLES) { + const model = generateSettlement({ cols: cfg.cols, rows: cfg.rows, style: cfg.style, seed: cfg.seed }); + const svg = renderSettlementSVG({ model, gridSize: 70, seed: cfg.seed }); + console.log(`${cfg.name.padEnd(12)} buildings:${model.buildings.length} walls:${model.segments.length} trees:${model.trees.length} plots:${model.plots.length}`); + writeFileSync(join(here, `${cfg.name}.svg`), svg); +} +console.log("\nWrote settlement samples to", here); diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..9586841 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,5 @@ +{ + "MAPWRIGHT.Title": "Mapwright — Battle Map Generator", + "MAPWRIGHT.ToolbarOpen": "Generate Battle Map (Mapwright)", + "MAPWRIGHT.SceneButton": "Generate Map" +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..b48c68c --- /dev/null +++ b/module.json @@ -0,0 +1,34 @@ +{ + "id": "mapwright", + "title": "Mapwright — Battle Map Generator", + "description": "One-click procedural battle maps for any Foundry world. Generates good-looking floor plans, buildings and dungeons with Foundry walls, doors and lighting already in place. Designed to be usable with no prior experience.", + "version": "0.5.0", + "compatibility": { + "minimum": "12", + "verified": "14.361", + "maximum": "14" + }, + "authors": [ + { + "name": "Tim Evans" + } + ], + "esmodules": [ + "module/mapwright.mjs" + ], + "styles": [ + "styles/mapwright.css" + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "url": "https://github.com/slaguru666/mapwright", + "readme": "https://github.com/slaguru666/mapwright/blob/main/README.md", + "bugs": "https://github.com/slaguru666/mapwright/issues", + "manifest": "https://github.com/slaguru666/mapwright/releases/latest/download/module.json", + "download": "https://github.com/slaguru666/mapwright/releases/latest/download/mapwright.zip" +} diff --git a/module/app/generator-app.mjs b/module/app/generator-app.mjs new file mode 100644 index 0000000..d84bacd --- /dev/null +++ b/module/app/generator-app.mjs @@ -0,0 +1,314 @@ +/** + * Mapwright generator window (ApplicationV2 + Handlebars). + * + * Simple for inexperienced GMs: pick a category + style + size, watch the LIVE + * preview, hit Create Map. Preview uses the exact same pipeline as the final + * scene. Routes to building (single or multi-floor) / cave / outdoor engines. + */ + +import { Rng } from "../lib/rng.mjs"; +import { generateBuilding, generateBuildingComplex } from "../lib/building.mjs"; +import { buildWalls } from "../lib/walls.mjs"; +import { computePlacements } from "../lib/furniture-layout.mjs"; +import { renderSVG } from "../lib/render-vector.mjs"; +import { generateCave } from "../lib/dungeon.mjs"; +import { renderCaveSVG } from "../lib/render-cave.mjs"; +import { generateOutdoor } from "../lib/outdoor.mjs"; +import { renderOutdoorSVG } from "../lib/render-outdoor.mjs"; +import { generateSettlement } from "../lib/settlement.mjs"; +import { renderSettlementSVG } from "../lib/render-settlement.mjs"; +import { + buildRoomNotes, buildRoomLights, buildFeatureNotes, buildFeatureLights +} from "../lib/population.mjs"; +import { createScene, createSceneSet } from "../lib/scene.mjs"; + +const MODULE_ID = "mapwright"; +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +const CATEGORIES = [ + { value: "modern", label: "Modern Building" }, + { value: "fantasy", label: "Fantasy Building" }, + { value: "cave", label: "Cave / Dungeon" }, + { value: "outdoor", label: "Outdoors" }, + { value: "village", label: "Town / Village" }, + { value: "city", label: "City Block" } +]; + +const isSettlementCat = (c) => c === "village" || c === "city"; + +const VARIANTS = { + modern: [], fantasy: [], + cave: [ + { value: "cave", label: "Rough Cave" }, + { value: "crypt", label: "Undead Crypt" }, + { value: "sewer", label: "Sewer / Undercroft" } + ], + outdoor: [ + { value: "grassland", label: "Grassland" }, + { value: "desert", label: "Desert" }, + { value: "snow", label: "Snow" }, + { value: "badlands", label: "Badlands" } + ] +}; + +const SHAPES = [ + { value: "auto", label: "Auto (random)" }, + { value: "rectangle", label: "Rectangle" }, + { value: "l", label: "L-Shape" }, + { value: "t", label: "T-Shape" }, + { value: "u", label: "U-Shape" }, + { value: "plus", label: "Cross / Plus" } +]; + +const FLOORS = [1, 2, 3, 4]; + +const SIZE_PRESETS = { + small: { label: "Small", cols: 18, rows: 14, targetRooms: 6 }, + medium: { label: "Medium", cols: 26, rows: 18, targetRooms: 11 }, + large: { label: "Large", cols: 34, rows: 24, targetRooms: 18 } +}; + +const GRID_SIZES = [ + { value: 70, label: "70 px" }, + { value: 100, label: "100 px (standard)" }, + { value: 140, label: "140 px" } +]; + +const BG = { + modern: "#23262b", fantasy: "#2a241c", + cave: "#0f0d0b", crypt: "#0c0e12", sewer: "#0c100e", + grassland: "#5b7a3a", desert: "#c9a96a", snow: "#dfe6ec", badlands: "#9c6a44", + village: "#5d7c3c", city: "#2f3338" +}; + +const isBuildingCat = (c) => c === "modern" || c === "fantasy"; + +/** Build one building-floor MapResult (shared by single + multi-floor). */ +function buildingFloor(layout, cfg, subtype, gs, floorIndex, floorNames) { + const walls = buildWalls(layout, { rng: new Rng(`${cfg.seed}::walls::${floorIndex}`), windows: cfg.windows }); + const placements = cfg.furniture ? computePlacements(layout, walls, gs, subtype, `${cfg.seed}::${floorIndex}`) : []; + const common = { layout, walls, gridSize: gs, subtype, seed: `${cfg.seed}::${floorIndex}`, showGrid: cfg.showGrid, showLabels: cfg.showLabels }; + // Background image has NO furniture (furniture is placed as locked Tiles); + // the preview draws the furniture vector so it stays WYSIWYG. + const svg = renderSVG({ ...common, showFurniture: false }); + const previewSvg = renderSVG({ ...common, showFurniture: cfg.furniture, placements }); + const notes = cfg.createNotes ? buildRoomNotes(layout, gs) : []; + if (cfg.createNotes && layout.circulation && floorNames) { + const up = floorNames[floorIndex + 1], down = floorNames[floorIndex - 1]; + const parts = []; + if (down) parts.push(`Down → ${down}`); + if (up) parts.push(`Up → ${up}`); + if (parts.length) notes.push(...buildFeatureNotes([{ x: layout.circulation.x, y: layout.circulation.y, label: parts.join(" · ") }], gs)); + } + return { + width: layout.cols * gs, height: layout.rows * gs, gridSize: gs, + backgroundColor: BG[subtype], svg, previewSvg, placements, segments: walls.segments, notes, + lights: cfg.placeLights ? buildRoomLights(layout, gs, subtype) : [], + tokenVision: !!cfg.placeLights, floorName: layout.floorName, + stats: `${layout.rooms.length} rooms · ${walls.doors.length} doors · ${placements.length} props` + }; +} + +/** Uniform MapResult (or multi-floor wrapper) for any category. */ +function compose(cfg) { + const gs = cfg.gridSize; + + if (isBuildingCat(cfg.category)) { + const subtype = cfg.category; + const floors = Math.max(1, cfg.floors || 1); + if (floors > 1) { + const complex = generateBuildingComplex({ + cols: cfg.cols, rows: cfg.rows, targetRooms: cfg.targetRooms, + subtype, seed: cfg.seed, shape: cfg.shape, floors + }); + const results = complex.floors.map((layout, i) => buildingFloor(layout, cfg, subtype, gs, i, complex.floorNames)); + return { multi: true, floors: results, preview: results[0].previewSvg, width: results[0].width, height: results[0].height, stats: `${floors} floors · ${results[0].stats}` }; + } + const layout = generateBuilding({ cols: cfg.cols, rows: cfg.rows, targetRooms: cfg.targetRooms, subtype, seed: cfg.seed, shape: cfg.shape }); + return buildingFloor(layout, cfg, subtype, gs, 0, null); + } + + if (cfg.category === "cave") { + const theme = cfg.variant || "cave"; + const model = generateCave({ cols: cfg.cols, rows: cfg.rows, seed: cfg.seed }); + const svg = renderCaveSVG({ model, gridSize: gs, theme, seed: cfg.seed, showGrid: cfg.showGrid, showLabels: cfg.showLabels }); + return { + width: cfg.cols * gs, height: cfg.rows * gs, gridSize: gs, backgroundColor: BG[theme] ?? BG.cave, svg, + segments: model.segments, + notes: cfg.createNotes ? buildFeatureNotes(model.features, gs) : [], + lights: cfg.placeLights ? buildFeatureLights(model.features, gs) : [], + tokenVision: !!cfg.placeLights, + stats: `${model.loops.length} chambers · ${model.features.length} features` + }; + } + + if (isSettlementCat(cfg.category)) { + const model = generateSettlement({ cols: cfg.cols, rows: cfg.rows, style: cfg.category, seed: cfg.seed }); + const svg = renderSettlementSVG({ model, gridSize: gs, seed: cfg.seed, showGrid: cfg.showGrid, showLabels: cfg.showLabels }); + return { + width: cfg.cols * gs, height: cfg.rows * gs, gridSize: gs, backgroundColor: BG[cfg.category], svg, + segments: model.segments, + notes: cfg.createNotes ? buildFeatureNotes(model.features, gs) : [], + lights: [], tokenVision: false, + stats: `${model.buildings.length} buildings · ${model.segments.length} wall segments` + }; + } + + const biome = cfg.variant || "grassland"; + const model = generateOutdoor({ cols: cfg.cols, rows: cfg.rows, biome, seed: cfg.seed }); + const svg = renderOutdoorSVG({ model, gridSize: gs, seed: cfg.seed, showGrid: cfg.showGrid, showLabels: cfg.showLabels }); + return { + width: cfg.cols * gs, height: cfg.rows * gs, gridSize: gs, backgroundColor: BG[biome] ?? BG.grassland, svg, + segments: model.segments, + notes: cfg.createNotes ? buildFeatureNotes(model.features, gs) : [], + lights: [], tokenVision: false, + stats: `${model.trees.length} trees · ${model.rocks.length} rocks · water ${model.water ? "yes" : "no"}` + }; +} + +function randomSeed() { + const words = ["amber", "raven", "iron", "hollow", "vault", "ash", "north", "grim", "pale", "oak", "mire", "stone"]; + return `${words[Math.floor(Math.random() * words.length)]}-${Math.floor(Math.random() * 900 + 100)}`; +} + +export class MapwrightApp extends HandlebarsApplicationMixin(ApplicationV2) { + #busy = false; + + static DEFAULT_OPTIONS = { + id: MODULE_ID, + classes: ["mapwright"], + tag: "div", + window: { title: "MAPWRIGHT.Title", resizable: true, icon: "fas fa-map" }, + position: { width: 1060, height: 760 }, + actions: { reroll: MapwrightApp.#onReroll, create: MapwrightApp.#onCreate } + }; + + static PARTS = { body: { template: `modules/${MODULE_ID}/templates/generator.html` } }; + + static open() { return new MapwrightApp().render({ force: true }); } + + async _prepareContext() { + const defaults = { + name: "New Map", category: "modern", shape: "auto", floors: 1, size: "medium", + gridSize: 100, seed: randomSeed(), windows: true, furniture: true, + placeLights: false, createNotes: true, showGrid: true, showLabels: true + }; + return { + categories: CATEGORIES.map((o) => ({ ...o, selected: o.value === defaults.category })), + shapes: SHAPES.map((o) => ({ ...o, selected: o.value === defaults.shape })), + floors: FLOORS.map((n) => ({ value: n, label: n === 1 ? "1 floor" : `${n} floors`, selected: n === defaults.floors })), + sizes: Object.entries(SIZE_PRESETS).map(([value, p]) => ({ value, label: p.label, selected: value === defaults.size })), + gridSizes: GRID_SIZES.map((o) => ({ ...o, selected: o.value === defaults.gridSize })), + defaults + }; + } + + _onRender() { + const root = this.element; + root.querySelectorAll("[data-preview]").forEach((el) => { + const evt = el.tagName === "INPUT" && el.type === "text" ? "input" : "change"; + el.addEventListener(evt, () => { + if (el.name === "category") this.#syncCategory(); + this.#debouncedPreview(); + }); + }); + this.#syncCategory(); + this.#updatePreview(); + } + + #syncCategory() { + const root = this.element; + const cat = root.querySelector('[name="category"]')?.value ?? "modern"; + const building = isBuildingCat(cat); + const variants = VARIANTS[cat] ?? []; + const variantField = root.querySelector(".mapwright-variant"); + const variantSelect = root.querySelector('[name="variant"]'); + if (variantSelect) { + variantSelect.innerHTML = variants.map((v) => ``).join(""); + variantField.style.display = variants.length ? "" : "none"; + } + const show = (sel, on) => { const el = root.querySelector(sel); if (el) el.style.display = on ? "" : "none"; }; + show(".mapwright-shape", building); + show(".mapwright-floors", building); + show(".mw-opt-windows", building); + show(".mw-opt-furniture", building); + show(".mw-opt-lights", building || cat === "cave"); + } + + #readConfig() { + const root = this.element; + const v = (name) => root.querySelector(`[name="${name}"]`); + const size = v("size")?.value ?? "medium"; + const preset = SIZE_PRESETS[size] ?? SIZE_PRESETS.medium; + return { + name: (v("name")?.value || "New Map").trim(), + category: v("category")?.value ?? "modern", + variant: v("variant")?.value ?? "", + shape: v("shape")?.value ?? "rectangle", + floors: Number(v("floors")?.value ?? 1), + size, cols: preset.cols, rows: preset.rows, targetRooms: preset.targetRooms, + gridSize: Number(v("gridSize")?.value ?? 100), + seed: (v("seed")?.value || "mapwright").trim(), + windows: v("windows")?.checked ?? true, + furniture: v("furniture")?.checked ?? true, + placeLights: v("placeLights")?.checked ?? true, + createNotes: v("createNotes")?.checked ?? true, + showGrid: v("showGrid")?.checked ?? true, + showLabels: v("showLabels")?.checked ?? true + }; + } + + #debouncedPreview() { + clearTimeout(this._t); + this._t = setTimeout(() => this.#updatePreview(), 120); + } + + #updatePreview() { + const cfg = this.#readConfig(); + const host = this.element.querySelector(".mapwright-preview"); + const stats = this.element.querySelector(".mapwright-stats"); + if (!host) return; + try { + const result = compose(cfg); + host.innerHTML = result.previewSvg ?? result.svg ?? result.preview ?? ""; + if (stats) stats.textContent = result.stats ?? ""; + } catch (err) { + console.error("Mapwright preview failed", err); + host.innerHTML = `

Preview failed: ${err.message}

`; + } + } + + static #onReroll() { + const seedInput = this.element.querySelector('[name="seed"]'); + if (seedInput) seedInput.value = randomSeed(); + this.#updatePreview(); + } + + static async #onCreate() { + if (this.#busy) return; + this.#busy = true; + const button = this.element.querySelector('[data-action="create"]'); + if (button) button.disabled = true; + try { + const cfg = this.#readConfig(); + const result = compose(cfg); + const tokenVision = result.multi ? result.floors[0]?.tokenVision : result.tokenVision; + const meta = { name: cfg.name, gridSize: cfg.gridSize, seed: cfg.seed, subtype: cfg.variant || cfg.category, tokenVision }; + ui.notifications.info(`Mapwright: building "${cfg.name}"…`); + if (result.multi) { + await createSceneSet(result.floors, meta); + } else { + const scene = await createScene(result, meta); + await scene?.view?.(); + } + ui.notifications.info(`Mapwright: "${cfg.name}" created.`); + this.close(); + } catch (err) { + console.error("Mapwright generation failed", err); + ui.notifications.error(`Mapwright failed: ${err.message}`); + } finally { + this.#busy = false; + if (button) button.disabled = false; + } + } +} diff --git a/module/lib/building.mjs b/module/lib/building.mjs new file mode 100644 index 0000000..12ac023 --- /dev/null +++ b/module/lib/building.mjs @@ -0,0 +1,293 @@ +/** + * Building layout via Binary Space Partitioning over an arbitrary FOOTPRINT. + * + * The footprint is a boolean mask (rectangle / L / T / U / plus). BSP splits the + * bounding box; rooms are then clipped to the mask, so exterior walls follow the + * real (non-rectangular) outline. Supports a forced vertical-circulation room + * (stairwell / elevator) for multi-floor buildings. + * + * Pure / Node-testable. Coordinates in GRID CELLS. + */ + +import { Rng } from "./rng.mjs"; + +const ROOM_VOCAB = { + modern: { + entrance: { label: "Lobby", type: "entrance" }, + circulation: { label: "Elevator", type: "elevator" }, + rooms: [ + { label: "Office", weight: 10, numbered: true }, + { label: "Meeting Room", weight: 4, numbered: true }, + { label: "Break Room", weight: 2, unique: true }, + { label: "Server Room", weight: 2, unique: true, locked: true }, + { label: "Restroom", weight: 3, numbered: true }, + { label: "Storage", weight: 4, numbered: true }, + { label: "Reception", weight: 1, unique: true }, + { label: "Exec Office", weight: 1, unique: true, locked: true } + ] + }, + fantasy: { + entrance: { label: "Entry Hall", type: "entrance" }, + circulation: { label: "Stairwell", type: "stairs" }, + rooms: [ + { label: "Bed Chamber", weight: 6, numbered: true }, + { label: "Common Room", weight: 2, unique: true }, + { label: "Kitchen", weight: 2, unique: true }, + { label: "Store Room", weight: 4, numbered: true }, + { label: "Study", weight: 2, unique: true }, + { label: "Guard Room", weight: 3, numbered: true }, + { label: "Cellar", weight: 1, unique: true, locked: true }, + { label: "Strong Room", weight: 1, unique: true, locked: true } + ] + } +}; + +export const FOOTPRINT_SHAPES = ["rectangle", "l", "t", "u", "plus"]; + +function rectArea(r) { return r.w * r.h; } +function splittable(leaf, minRoom) { return leaf.w >= 2 * minRoom || leaf.h >= 2 * minRoom; } + +function splitLeaf(leaf, minRoom, rng) { + const canV = leaf.w >= 2 * minRoom; + const canH = leaf.h >= 2 * minRoom; + let cutVertical; + if (canV && canH) cutVertical = rng.float() < leaf.w / (leaf.w + leaf.h); + else cutVertical = canV; + + if (cutVertical) { + const lo = leaf.x + minRoom, hi = leaf.x + leaf.w - minRoom; + const p = Math.round(lo + ((rng.float() + rng.float()) / 2) * (hi - lo)); + return [ + { x: leaf.x, y: leaf.y, w: p - leaf.x, h: leaf.h }, + { x: p, y: leaf.y, w: leaf.x + leaf.w - p, h: leaf.h } + ]; + } + const lo = leaf.y + minRoom, hi = leaf.y + leaf.h - minRoom; + const p = Math.round(lo + ((rng.float() + rng.float()) / 2) * (hi - lo)); + return [ + { x: leaf.x, y: leaf.y, w: leaf.w, h: p - leaf.y }, + { x: leaf.x, y: p, w: leaf.w, h: leaf.y + leaf.h - p } + ]; +} + +/** + * Boolean footprint mask [rows][cols]. Deterministic from its own seed so all + * floors of a building share the same outline. + */ +export function buildFootprintMask(opts) { + const { cols, rows } = opts; + const margin = opts.margin ?? 1; + const rng = new Rng(`${opts.seed ?? "mask"}::mask`); + // "auto" (or anything unknown) -> pick a shape deterministically from the seed, + // weighted so plain rectangles still appear fairly often. + let shape = opts.shape; + if (!FOOTPRINT_SHAPES.includes(shape)) { + shape = rng.weighted([ + { v: "rectangle", weight: 3 }, { v: "l", weight: 2 }, { v: "t", weight: 2 }, + { v: "u", weight: 2 }, { v: "plus", weight: 1 } + ]).v; + } + const x0 = margin, y0 = margin, x1 = cols - margin, y1 = rows - margin; + const w = x1 - x0, h = y1 - y0; + const mask = Array.from({ length: rows }, () => new Array(cols).fill(false)); + const inRect = (x, y, rx, ry, rw, rh) => x >= rx && y >= ry && x < rx + rw && y < ry + rh; + + // Per-shape "keep" predicate over the inner rect. + let keep; + if (shape === "l") { + const cutW = Math.round(w * (0.4 + rng.float() * 0.15)); + const cutH = Math.round(h * (0.4 + rng.float() * 0.15)); + const corner = rng.int(0, 3); // which corner is removed + keep = (x, y) => { + const rx = corner === 1 || corner === 3 ? x1 - cutW : x0; + const ry = corner === 2 || corner === 3 ? y1 - cutH : y0; + return !inRect(x, y, rx, ry, cutW, cutH); + }; + } else if (shape === "t") { + const barH = Math.round(h * (0.32 + rng.float() * 0.12)); + const stemX = x0 + Math.round(w * (0.3 + rng.float() * 0.1)); + const stemW = Math.round(w * (0.3 + rng.float() * 0.1)); + keep = (x, y) => (y < y0 + barH) || (x >= stemX && x < stemX + stemW); + } else if (shape === "u") { + const wingW = Math.round(w * (0.26 + rng.float() * 0.08)); + const baseY = y1 - Math.round(h * (0.32 + rng.float() * 0.1)); + keep = (x, y) => (x < x0 + wingW) || (x >= x1 - wingW) || (y >= baseY); + } else if (shape === "plus") { + const armW = Math.round(w * (0.34 + rng.float() * 0.08)); + const armH = Math.round(h * (0.34 + rng.float() * 0.08)); + const cx = x0 + Math.round((w - armW) / 2); + const cy = y0 + Math.round((h - armH) / 2); + keep = (x, y) => (x >= cx && x < cx + armW) || (y >= cy && y < cy + armH); + } else { + keep = () => true; + } + + for (let y = y0; y < y1; y += 1) { + for (let x = x0; x < x1; x += 1) { + if (keep(x, y)) mask[y][x] = true; + } + } + return { mask, x0, y0, x1, y1, shape }; +} + +/** + * Generate one building floor. + * @param {object} opts cols, rows, targetRooms, subtype, seed, shape, margin, + * minRoom, circulation:{x,y}|null, floorName + */ +export function generateBuilding(opts) { + const cols = opts.cols, rows = opts.rows; + const margin = opts.margin ?? 1; + const minRoom = opts.minRoom ?? 3; + const subtype = ROOM_VOCAB[opts.subtype] ? opts.subtype : "modern"; + const rng = new Rng(opts.seed ?? "mapwright"); + + const { mask, x0, y0, x1, y1, shape: resolvedShape } = buildFootprintMask({ cols, rows, margin, shape: opts.shape, seed: opts.maskSeed ?? opts.seed }); + const bbox = { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; + + // BSP the bounding box. + const target = Math.max(1, opts.targetRooms ?? 8); + let leaves = [{ ...bbox }]; + let guard = 0; + while (leaves.length < target && guard < 500) { + guard += 1; + const cands = leaves.map((leaf, i) => ({ leaf, i })).filter(({ leaf }) => splittable(leaf, minRoom)); + if (!cands.length) break; + cands.sort((a, b) => rectArea(b.leaf) - rectArea(a.leaf)); + const choice = cands[Math.min(cands.length - 1, Math.floor(rng.float() * rng.float() * 2))]; + const [a, b] = splitLeaf(choice.leaf, minRoom, rng); + leaves.splice(choice.i, 1, a, b); + } + + // Assign masked cells to the leaf that contains them. + const leafAt = (x, y) => leaves.findIndex((l) => x >= l.x && y >= l.y && x < l.x + l.w && y < l.y + l.h); + const cellsByLeaf = leaves.map(() => []); + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + if (!mask[y][x]) continue; + const li = leafAt(x, y); + if (li >= 0) cellsByLeaf[li].push({ x, y }); + } + } + + // Materialise rooms (drop empty leaves, relabel ids). + const rooms = []; + for (let li = 0; li < leaves.length; li += 1) { + const cells = cellsByLeaf[li]; + if (cells.length < 2) continue; + let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity, sx = 0, sy = 0; + for (const c of cells) { + minx = Math.min(minx, c.x); miny = Math.min(miny, c.y); + maxx = Math.max(maxx, c.x + 1); maxy = Math.max(maxy, c.y + 1); + sx += c.x + 0.5; sy += c.y + 0.5; + } + rooms.push({ + id: rooms.length, x: minx, y: miny, w: maxx - minx, h: maxy - miny, + cx: sx / cells.length, cy: sy / cells.length, + cells, type: "room", label: "", locked: false + }); + } + + // Region grid. + const region = Array.from({ length: rows }, () => new Array(cols).fill(-1)); + for (const room of rooms) for (const c of room.cells) region[c.y][c.x] = room.id; + + // Forced vertical-circulation room (multi-floor). + let circulation = null; + if (opts.circulation) { + const cc = opts.circulation; + const id = region[cc.y]?.[cc.x] ?? -1; + const room = rooms.find((r) => r.id === id); + if (room) { + const vocab = ROOM_VOCAB[subtype].circulation; + room.type = vocab.type; + room.label = vocab.label; + room.locked = false; + room.reserved = true; + circulation = { x: cc.x + 0.5, y: cc.y + 0.5, kind: vocab.type, label: vocab.label, roomId: room.id }; + } + } + + assignRoomTypes(rooms, bbox, subtype, rng); + + return { footprint: bbox, mask, rooms, region, cols, rows, shape: resolvedShape, circulation, floorName: opts.floorName }; +} + +/** + * Generate a multi-floor building: an array of floor layouts sharing one + * footprint + a vertical-circulation column at a consistent location. + * @param {object} opts ...generateBuilding opts + floors:number + * @returns {{ floors: object[], shape, circulationCell }} + */ +export function generateBuildingComplex(opts) { + const floors = Math.max(1, opts.floors ?? 1); + const { mask, x0, y0, x1, y1 } = buildFootprintMask({ ...opts, seed: opts.seed }); + + // Choose a circulation cell near the footprint centroid that is inside the mask. + let circ = null; + if (floors > 1) { + const rng = new Rng(`${opts.seed}::circ`); + let cx = 0, cy = 0, n = 0; + for (let y = y0; y < y1; y += 1) for (let x = x0; x < x1; x += 1) if (mask[y][x]) { cx += x; cy += y; n += 1; } + cx = Math.round(cx / Math.max(1, n)); cy = Math.round(cy / Math.max(1, n)); + // search outward for the nearest masked cell + let best = null, bestD = Infinity; + for (let y = y0; y < y1; y += 1) for (let x = x0; x < x1; x += 1) { + if (!mask[y][x]) continue; + const d = (x - cx) ** 2 + (y - cy) ** 2; + if (d < bestD) { bestD = d; best = { x, y }; } + } + circ = best ?? { x: cx, y: cy }; + void rng; + } + + const floorNames = makeFloorNames(opts.subtype, floors); + const out = []; + for (let i = 0; i < floors; i += 1) { + out.push(generateBuilding({ + ...opts, + seed: `${opts.seed}::floor${i}`, + maskSeed: opts.seed, // identical footprint on every floor + circulation: circ, + floorName: floorNames[i] + })); + } + return { floors: out, shape: opts.shape ?? "rectangle", circulationCell: circ, floorNames }; +} + +function makeFloorNames(subtype, n) { + if (subtype === "fantasy") { + const names = ["Ground Floor", "Upper Floor", "Top Floor", "Attic", "Cellar"]; + return Array.from({ length: n }, (_, i) => names[i] ?? `Level ${i + 1}`); + } + return Array.from({ length: n }, (_, i) => (i === 0 ? "Ground Floor" : `Floor ${i + 1}`)); +} + +function assignRoomTypes(rooms, footprint, subtype, rng) { + const vocab = ROOM_VOCAB[subtype]; + const touchesEdge = (r) => + r.x === footprint.x || r.y === footprint.y || + r.x + r.w === footprint.x + footprint.w || r.y + r.h === footprint.y + footprint.h; + + const candidates = rooms.filter((r) => !r.reserved); + const edgeRooms = candidates.filter(touchesEdge); + const southRooms = edgeRooms.filter((r) => r.y + r.h === footprint.y + footprint.h); + const pool = (southRooms.length ? southRooms : edgeRooms).slice().sort((a, b) => rectArea(b) - rectArea(a)); + const entrance = pool[0] ?? candidates[0]; + if (entrance) { entrance.type = vocab.entrance.type; entrance.label = vocab.entrance.label; entrance.reserved = true; } + + const used = new Set(); + const counters = {}; + for (const room of rooms) { + if (room.reserved || room === entrance) continue; + const available = vocab.rooms.filter((r) => !(r.unique && used.has(r.label))); + const pickList = available.length ? available : vocab.rooms; + const choice = rng.weighted(pickList); + used.add(choice.label); + room.locked = !!choice.locked; + if (choice.numbered) { + counters[choice.label] = (counters[choice.label] ?? 0) + 1; + room.label = `${choice.label} ${counters[choice.label]}`; + } else room.label = choice.label; + } +} diff --git a/module/lib/dungeon.mjs b/module/lib/dungeon.mjs new file mode 100644 index 0000000..fc03a1c --- /dev/null +++ b/module/lib/dungeon.mjs @@ -0,0 +1,114 @@ +/** + * Dungeon generator — organic caves (no right angles). + * + * Wraps the organic core into a usable model: floor blob, smoothed contour + * loops for rendering, simplified diagonal wall segments for Foundry, and a + * handful of labelled feature points. Pure / Node-testable. + */ + +import { Rng } from "./rng.mjs"; +import { + generateBlob, traceContours, loopToCells, + simplifyClosed, chaikin, signedArea +} from "./organic.mjs"; + +const FEATURE_LABELS = [ + "Entrance", "Collapsed Tunnel", "Underground Pool", "Old Campsite", + "Mushroom Grove", "Bone Pile", "Crystal Vein", "Narrow Squeeze", + "Cave-in", "Stagnant Water", "Ancient Carving" +]; + +function clean(loop) { + const out = []; + for (const p of loop) { + const last = out[out.length - 1]; + if (!last || last.x !== p.x || last.y !== p.y) out.push(p); + } + if (out.length > 1) { + const a = out[0], b = out[out.length - 1]; + if (a.x === b.x && a.y === b.y) out.pop(); + } + return out; +} + +/** + * @param {object} opts cols, rows, seed, subRes, fill, steps, simplifyTol, smooth, features + * @returns {{ cols, rows, subRes, floor, loops, segments, features, floorCells }} + */ +export function generateCave(opts) { + const blob = generateBlob({ + cols: opts.cols, rows: opts.rows, subRes: opts.subRes ?? 3, + seed: opts.seed, fill: opts.fill ?? 0.46, steps: opts.steps ?? 5, margin: opts.margin ?? 1 + }); + + const rawLoops = traceContours(blob.floor, blob.sw, blob.sh); + const simplifyTol = opts.simplifyTol ?? 0.45; + const smoothIter = opts.smooth ?? 2; + const minLoopArea = opts.minLoopArea ?? 1.2; + + const loops = []; + for (const raw of rawLoops) { + const cells = clean(loopToCells(raw, blob.subRes)); + if (cells.length < 4) continue; + const simp = simplifyClosed(cells, simplifyTol); + if (simp.length < 3) continue; + const area = signedArea(simp); + if (Math.abs(area) < minLoopArea) continue; + loops.push({ simp, smooth: chaikin(simp, smoothIter), area }); + } + + // Wall segments (diagonal, organic) for Foundry from the simplified loops. + const segments = []; + for (const lp of loops) { + const p = lp.simp; + for (let i = 0; i < p.length; i += 1) { + const a = p[i], b = p[(i + 1) % p.length]; + segments.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y, kind: "wall" }); + } + } + + const features = placeFeatures(blob, opts); + + return { + cols: opts.cols, rows: opts.rows, subRes: blob.subRes, + floor: blob.floor, loops, segments, features + }; +} + +/** Scatter a few labelled feature points on floor cells, well spaced. */ +function placeFeatures(blob, opts) { + const rng = new Rng(`${opts.seed}::features`); + const { floor, sw, sh, subRes } = blob; + const floorPts = []; + for (let y = 0; y < sh; y += 1) { + for (let x = 0; x < sw; x += 1) { + if (floor[y * sw + x] === 1) floorPts.push({ x, y }); + } + } + if (!floorPts.length) return []; + + const want = Math.min(opts.featureCount ?? Math.round((opts.cols * opts.rows) / 90) + 1, FEATURE_LABELS.length); + const minSep = Math.max(3, Math.min(blob.cols, blob.rows) * 0.18) * subRes; + const labels = rng.shuffle(FEATURE_LABELS.slice()); + const chosen = []; + + // Entrance: floor point closest to the grid edge. + let entrance = floorPts[0]; + let bestEdge = Infinity; + for (const p of floorPts) { + const d = Math.min(p.x, p.y, sw - p.x, sh - p.y); + if (d < bestEdge) { bestEdge = d; entrance = p; } + } + chosen.push({ x: entrance.x / subRes, y: entrance.y / subRes, label: "Entrance" }); + + let guard = 0; + while (chosen.length < want && guard++ < 2000) { + const c = rng.pick(floorPts); + const tooClose = chosen.some((o) => Math.hypot(o.x * subRes - c.x, o.y * subRes - c.y) < minSep); + if (tooClose) continue; + const label = labels[chosen.length % labels.length]; + if (label === "Entrance") continue; + chosen.push({ x: c.x / subRes, y: c.y / subRes, label }); + } + return chosen; +} diff --git a/module/lib/furniture-layout.mjs b/module/lib/furniture-layout.mjs new file mode 100644 index 0000000..35e812a --- /dev/null +++ b/module/lib/furniture-layout.mjs @@ -0,0 +1,91 @@ +/** + * Room-aware furniture placement. + * + * Produces grid-aligned, non-overlapping placements that sit inside rooms, + * prefer walls for wall-pieces, and keep doorways clear. The SAME placements + * feed both the live preview (vector) and the locked Tiles placed in the scene, + * so they always match. Pure / Node-testable. Output px uses the given gridSize. + * + * Placement shape: + * { kind, subtype, rotation(0|90), cx, cy (centre px), wpx, hpx (native px) } + */ + +import { Rng } from "./rng.mjs"; +import { PIECES, piecesFor } from "./furniture.mjs"; + +/** Cells adjacent to a door — kept clear so furniture never blocks a doorway. */ +function buildKeepClear(walls) { + const set = new Set(); + for (const s of walls.segments ?? []) { + if (s.kind !== "door") continue; + if (s.orient === "v") { set.add(`${s.x1 - 1},${s.y1}`); set.add(`${s.x1},${s.y1}`); } + else { set.add(`${s.x1},${s.y1 - 1}`); set.add(`${s.x1},${s.y1}`); } + } + return set; +} + +export function computePlacements(layout, walls, gs, subtype, seed) { + const { region, cols, rows, rooms } = layout; + const rng = new Rng(`${seed}::furnlayout`); + const occupied = Array.from({ length: rows }, () => new Array(cols).fill(false)); + const keepClear = buildKeepClear(walls); + const placements = []; + + const inRoom = (id, x, y) => x >= 0 && y >= 0 && x < cols && y < rows && region[y][x] === id; + + for (const room of rooms) { + if (!room.label || room.type === "stairs" || room.type === "elevator") continue; + const kinds = piecesFor(room.label); + + for (const kind of kinds) { + const def = PIECES[kind]; + if (!def) continue; + const [pw, ph] = def.footprint; + const orients = pw === ph ? [[pw, ph, 0]] : [[pw, ph, 0], [ph, pw, 90]]; + const cands = []; + + for (const [ow, oh, rot] of orients) { + for (let ty = room.y; ty <= room.y + room.h - oh; ty += 1) { + for (let tx = room.x; tx <= room.x + room.w - ow; tx += 1) { + let ok = true; + for (let dy = 0; dy < oh && ok; dy += 1) { + for (let dx = 0; dx < ow && ok; dx += 1) { + const cx = tx + dx, cy = ty + dy; + if (!inRoom(room.id, cx, cy) || occupied[cy][cx] || keepClear.has(`${cx},${cy}`)) ok = false; + } + } + if (!ok) continue; + let wallScore = 0; + for (let dx = 0; dx < ow; dx += 1) { + if (!inRoom(room.id, tx + dx, ty - 1)) wallScore += 1; + if (!inRoom(room.id, tx + dx, ty + oh)) wallScore += 1; + } + for (let dy = 0; dy < oh; dy += 1) { + if (!inRoom(room.id, tx - 1, ty + dy)) wallScore += 1; + if (!inRoom(room.id, tx + ow, ty + dy)) wallScore += 1; + } + cands.push({ tx, ty, ow, oh, rot, wallScore }); + } + } + } + if (!cands.length) continue; + + // Wall pieces want maximum wall contact; free pieces prefer open centre. + const scores = cands.map((c) => c.wallScore); + const target = def.wall ? Math.max(...scores) : Math.min(...scores); + const pool = cands.filter((c) => c.wallScore === target); + const pick = pool[Math.floor(rng.float() * pool.length)]; + + for (let dy = 0; dy < pick.oh; dy += 1) { + for (let dx = 0; dx < pick.ow; dx += 1) occupied[pick.ty + dy][pick.tx + dx] = true; + } + placements.push({ + kind, subtype, rotation: pick.rot, + wpx: pw * gs, hpx: ph * gs, + cx: (pick.tx + pick.ow / 2) * gs, + cy: (pick.ty + pick.oh / 2) * gs + }); + } + } + return placements; +} diff --git a/module/lib/furniture.mjs b/module/lib/furniture.mjs new file mode 100644 index 0000000..914b206 --- /dev/null +++ b/module/lib/furniture.mjs @@ -0,0 +1,150 @@ +/** + * Furniture piece registry + room-type mapping. + * + * Each piece declares a footprint (in grid cells) and a draw() that renders the + * art to FILL a box of (wpx × hpx), centred, on a transparent background. The + * same registry feeds (a) the live preview vector, (b) the sprite PNGs used for + * locked Tile placement in the scene. System-agnostic, no external art. + */ + +export const PAL = { + modern: { wood: "#9a8f80", woodLine: "#5d5448", metal: "#9aa3ad", metalLine: "#5b636c", fabric: "#7d8ba0", line: "#3a3f45", soft: "#c4ccd4", seat: "#8b95a3" }, + fantasy: { wood: "#9c7b50", woodLine: "#4f3a20", metal: "#8a8d92", metalLine: "#4a4d52", fabric: "#8a6b52", line: "#3a2614", soft: "#c8b48f", seat: "#7a5532" } +}; + +const r2 = (n) => +n.toFixed(1); + +/** Map a room label to furniture kinds (in placement priority order). */ +export function piecesFor(label) { + const l = label.toLowerCase(); + if (/bed|chamber/.test(l)) return ["bed", "chest"]; + if (/exec|study/.test(l)) return ["desk", "shelf", "chair"]; + if (/office/.test(l)) return ["desk", "chair", "shelf"]; + if (/meeting|common|dining|ballroom/.test(l)) return ["bigtable"]; + if (/kitchen/.test(l)) return ["counter", "table"]; + if (/server/.test(l)) return ["racks", "racks"]; + if (/restroom|privy|bath/.test(l)) return ["toilet", "sink"]; + if (/storage|store/.test(l)) return ["crates", "shelf", "crates"]; + if (/strong|vault|cellar/.test(l)) return ["chest", "chest"]; + if (/reception|lobby|entry|foyer|hall/.test(l)) return ["counter"]; + if (/guard|barracks/.test(l)) return ["bunk", "table"]; + if (/break/.test(l)) return ["table", "counter"]; + return ["table"]; +} + +/** + * Piece registry. footprint = [wCells, hCells] in the piece's native (un-rotated) + * orientation. `wall` true → prefers sitting against a wall. `collide` false → + * decorative, never blocks (and may overlap). + */ +export const PIECES = { + bed: { footprint: [1, 2], wall: true, draw: drawBed }, + bunk: { footprint: [1, 2], wall: true, draw: drawBed }, + chest: { footprint: [1, 1], wall: true, draw: drawChest }, + desk: { footprint: [2, 1], wall: true, draw: drawDesk }, + chair: { footprint: [1, 1], wall: false, draw: drawChair }, + table: { footprint: [1, 1], wall: false, draw: drawTable }, + bigtable: { footprint: [3, 2], wall: false, draw: drawBigTable }, + counter: { footprint: [2, 1], wall: true, draw: drawCounter }, + racks: { footprint: [1, 1], wall: true, draw: drawRacks }, + toilet: { footprint: [1, 1], wall: true, draw: drawToilet }, + sink: { footprint: [1, 1], wall: true, draw: drawSink }, + crates: { footprint: [1, 1], wall: true, draw: drawCrates }, + shelf: { footprint: [1, 1], wall: true, draw: drawShelf } +}; + +/** Full standalone transparent SVG for one piece — used to make sprite PNGs. */ +export function pieceSpriteSVG(kind, subtype, wpx, hpx) { + const def = PIECES[kind]; + const pal = PAL[subtype] ?? PAL.modern; + const inner = def ? def.draw(wpx, hpx, pal) : ""; + return `${inner}`; +} + +/** Inner SVG for a piece (no wrapper) — used by the preview renderer. */ +export function pieceInnerSVG(kind, subtype, wpx, hpx) { + const def = PIECES[kind]; + const pal = PAL[subtype] ?? PAL.modern; + return def ? def.draw(wpx, hpx, pal) : ""; +} + +// --- piece art (drawn to fill a wpx×hpx box, small inset, transparent bg) ----- +function box(w, h, inset) { + const m = Math.min(w, h) * inset; + return { x: r2(m), y: r2(m), w: r2(w - 2 * m), h: r2(h - 2 * m), m: r2(m) }; +} + +function drawBed(w, h, pal) { + const b = box(w, h, 0.08); + const pillow = r2(b.h * 0.26); + return `` + + `` + + ``; +} +function drawChest(w, h, pal) { + const b = box(w, h, 0.18); + return `` + + ``; +} +function drawDesk(w, h, pal) { + const b = box(w, h, 0.1); + return `` + + ``; +} +function drawChair(w, h, pal) { + const b = box(w, h, 0.26); + return ``; +} +function drawTable(w, h, pal) { + const b = box(w, h, 0.16); + return ``; +} +function drawBigTable(w, h, pal) { + const b = box(w, h, 0.2); + let s = ``; + // chairs around + const cr = r2(Math.min(w, h) * 0.07); + for (let i = 0; i < 3; i += 1) { + const cx = r2(b.x + b.w * (0.2 + i * 0.3)); + s += ``; + s += ``; + } + s += ``; + return s + ``; +} +function drawCounter(w, h, pal) { + const b = box(w, h, 0.12); + const top = r2(b.h * 0.5); + return ``; +} +function drawRacks(w, h, pal) { + let s = ``; + const b = box(w, h, 0.12); + for (let i = 0; i < 3; i += 1) { + const x = r2(b.x + (b.w / 3) * i + b.w * 0.04); + s += ``; + } + return s + ``; +} +function drawToilet(w, h, pal) { + const cx = r2(w * 0.5); + return `` + + ``; +} +function drawSink(w, h, pal) { + const b = box(w, h, 0.24); + return ``; +} +function drawCrates(w, h, pal) { + let s = ``; + const sz = r2(Math.min(w, h) * 0.42); + for (const [ox, oy] of [[0.06, 0.1], [0.5, 0.16], [0.28, 0.52]]) { + s += ``; + } + return s + ``; +} +function drawShelf(w, h, pal) { + const x = r2(w * 0.08), y = r2(h * 0.1), bw = r2(w * 0.84), bh = r2(h * 0.34); + return `` + + ``; +} diff --git a/module/lib/organic.mjs b/module/lib/organic.mjs new file mode 100644 index 0000000..95b4ce0 --- /dev/null +++ b/module/lib/organic.mjs @@ -0,0 +1,261 @@ +/** + * Organic geometry core — shared by dungeons (caves) and outdoor biomes. + * + * Cellular-automata blob -> traced boundary loops -> simplified (diagonal, + * NON-axis-aligned) wall segments for Foundry + smoothed curves for the SVG. + * + * This is what frees us from the right-angle grid. Pure / Node-testable. + * Works on a SUB-CELL grid (subRes sub-cells per map cell) for smooth contours; + * all output is converted back to map-cell coordinates. + */ + +import { Rng } from "./rng.mjs"; + +/** + * Generate a cellular-automata cave/blob as a floor mask on the sub grid. + * @returns {{ floor: Uint8Array, sw: number, sh: number, subRes: number, cols: number, rows: number }} + */ +export function generateBlob(opts) { + const cols = opts.cols; + const rows = opts.rows; + const subRes = opts.subRes ?? 3; + const sw = cols * subRes; + const sh = rows * subRes; + const rng = new Rng(opts.seed ?? "cave"); + const fill = opts.fill ?? 0.46; + const steps = opts.steps ?? 5; + const margin = Math.max(1, Math.round((opts.margin ?? 1) * subRes)); + + let grid = new Uint8Array(sw * sh); // 1 = floor, 0 = wall + const at = (g, x, y) => (x < 0 || y < 0 || x >= sw || y >= sh ? 0 : g[y * sw + x]); + + // Seed noise, with a wall margin so the blob never touches the edge. + for (let y = 0; y < sh; y += 1) { + for (let x = 0; x < sw; x += 1) { + const edge = x < margin || y < margin || x >= sw - margin || y >= sh - margin; + grid[y * sw + x] = edge ? 0 : (rng.float() < fill ? 0 : 1); + } + } + + // Smooth with the classic 4-5 rule (+ fill large voids). + for (let s = 0; s < steps; s += 1) { + const next = new Uint8Array(sw * sh); + for (let y = 0; y < sh; y += 1) { + for (let x = 0; x < sw; x += 1) { + let walls1 = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) continue; + if (at(grid, x + dx, y + dy) === 0) walls1 += 1; + } + } + const isWall = grid[y * sw + x] === 0; + // Wall if surrounded; open up otherwise. + next[y * sw + x] = (isWall ? walls1 >= 4 : walls1 >= 5) ? 0 : 1; + } + } + grid = next; + } + + // Keep only the largest connected floor region (guarantees one cave). + grid = keepLargestRegion(grid, sw, sh); + + // Remove small enclosed wall pockets (declutter tiny pillars). + const holeFloor = (opts.removeHolesBelow ?? 2.2) * subRes * subRes; + grid = removeSmallHoles(grid, sw, sh, holeFloor); + + return { floor: grid, sw, sh, subRes, cols, rows }; +} + +/** Fill enclosed wall regions (not touching the border) smaller than minArea. */ +function removeSmallHoles(grid, sw, sh, minArea) { + const seen = new Uint8Array(sw * sh); + const stack = []; + for (let i = 0; i < grid.length; i += 1) { + if (grid[i] !== 0 || seen[i]) continue; + const cells = []; + let touchesBorder = false; + stack.length = 0; + stack.push(i); + seen[i] = 1; + while (stack.length) { + const idx = stack.pop(); + cells.push(idx); + const x = idx % sw, y = (idx / sw) | 0; + if (x === 0 || y === 0 || x === sw - 1 || y === sh - 1) touchesBorder = true; + const ns = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]; + for (const [nx, ny] of ns) { + if (nx < 0 || ny < 0 || nx >= sw || ny >= sh) continue; + const ni = ny * sw + nx; + if (grid[ni] === 0 && !seen[ni]) { seen[ni] = 1; stack.push(ni); } + } + } + if (!touchesBorder && cells.length < minArea) { + for (const idx of cells) grid[idx] = 1; + } + } + return grid; +} + +function keepLargestRegion(grid, sw, sh) { + const label = new Int32Array(sw * sh).fill(-1); + let best = -1, bestSize = 0, current = 0; + const stack = []; + for (let i = 0; i < grid.length; i += 1) { + if (grid[i] !== 1 || label[i] !== -1) continue; + let size = 0; + stack.length = 0; + stack.push(i); + label[i] = current; + while (stack.length) { + const idx = stack.pop(); + size += 1; + const x = idx % sw, y = (idx / sw) | 0; + const ns = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]; + for (const [nx, ny] of ns) { + if (nx < 0 || ny < 0 || nx >= sw || ny >= sh) continue; + const ni = ny * sw + nx; + if (grid[ni] === 1 && label[ni] === -1) { label[ni] = current; stack.push(ni); } + } + } + if (size > bestSize) { bestSize = size; best = current; } + current += 1; + } + const out = new Uint8Array(sw * sh); + if (best < 0) return out; + for (let i = 0; i < grid.length; i += 1) out[i] = label[i] === best ? 1 : 0; + return out; +} + +/** + * Trace the floor/wall boundary into ordered, oriented loops. + * Each loop is an array of {x, y} points in SUB-GRID coordinates. + */ +export function traceContours(floor, sw, sh) { + const at = (x, y) => (x < 0 || y < 0 || x >= sw || y >= sh ? 0 : floor[y * sw + x]); + // Directed boundary edges, floor-cell traversed clockwise (screen y-down). + const edges = new Map(); // tailKey -> [{tail,head}] + const addEdge = (ax, ay, bx, by) => { + const key = `${ax},${ay}`; + if (!edges.has(key)) edges.set(key, []); + edges.get(key).push({ head: { x: bx, y: by } }); + }; + for (let y = 0; y < sh; y += 1) { + for (let x = 0; x < sw; x += 1) { + if (at(x, y) !== 1) continue; + if (at(x, y - 1) === 0) addEdge(x, y, x + 1, y); // top + if (at(x + 1, y) === 0) addEdge(x + 1, y, x + 1, y + 1); // right + if (at(x, y + 1) === 0) addEdge(x + 1, y + 1, x, y + 1); // bottom + if (at(x - 1, y) === 0) addEdge(x, y + 1, x, y); // left + } + } + + const loops = []; + for (const [startKey, list] of edges) { + while (list.length) { + const first = list.pop(); + const startPt = startKey.split(",").map(Number); + const loop = [{ x: startPt[0], y: startPt[1] }]; + let cur = first.head; + let guard = 0; + while (guard++ < sw * sh * 4) { + loop.push({ x: cur.x, y: cur.y }); + const key = `${cur.x},${cur.y}`; + const outs = edges.get(key); + if (!outs || !outs.length) break; + const next = outs.pop(); + cur = next.head; + if (cur.x === startPt[0] && cur.y === startPt[1]) break; + } + if (loop.length > 3) loops.push(loop); + } + } + return loops; +} + +/** Chaikin corner-cutting smoothing for a closed loop. */ +export function chaikin(points, iterations = 2) { + let pts = points; + for (let it = 0; it < iterations; it += 1) { + const out = []; + const n = pts.length; + for (let i = 0; i < n; i += 1) { + const a = pts[i]; + const b = pts[(i + 1) % n]; + out.push({ x: a.x * 0.75 + b.x * 0.25, y: a.y * 0.75 + b.y * 0.25 }); + out.push({ x: a.x * 0.25 + b.x * 0.75, y: a.y * 0.25 + b.y * 0.75 }); + } + pts = out; + } + return pts; +} + +/** Douglas–Peucker simplification of a CLOSED loop. tol in same units as points. */ +export function simplifyClosed(points, tol) { + const n = points.length; + if (n < 5) return points.slice(); + // Split the loop at the two most distant points, DP each half. + let iFar = 0, jFar = 1, dMax = -1; + // cheap diameter estimate: farthest from point 0, then farthest from that + iFar = 0; + for (let k = 1; k < n; k += 1) { + const d = dist2(points[0], points[k]); + if (d > dMax) { dMax = d; jFar = k; } + } + dMax = -1; iFar = jFar; let opp = 0; + for (let k = 0; k < n; k += 1) { + const d = dist2(points[iFar], points[k]); + if (d > dMax) { dMax = d; opp = k; } + } + const a = Math.min(iFar, opp), b = Math.max(iFar, opp); + const half1 = points.slice(a, b + 1); + const half2 = points.slice(b).concat(points.slice(0, a + 1)); + const s1 = dpOpen(half1, tol); + const s2 = dpOpen(half2, tol); + // stitch, dropping duplicated shared endpoints + const merged = s1.concat(s2.slice(1, -1)); + return merged.length >= 3 ? merged : points.slice(); +} + +function dpOpen(points, tol) { + if (points.length < 3) return points.slice(); + const keep = new Array(points.length).fill(false); + keep[0] = keep[points.length - 1] = true; + const stack = [[0, points.length - 1]]; + while (stack.length) { + const [s, e] = stack.pop(); + let maxD = -1, idx = -1; + for (let i = s + 1; i < e; i += 1) { + const d = perpDist(points[i], points[s], points[e]); + if (d > maxD) { maxD = d; idx = i; } + } + if (maxD > tol && idx !== -1) { + keep[idx] = true; + stack.push([s, idx], [idx, e]); + } + } + return points.filter((_, i) => keep[i]); +} + +function dist2(a, b) { const dx = a.x - b.x, dy = a.y - b.y; return dx * dx + dy * dy; } + +function perpDist(p, a, b) { + const dx = b.x - a.x, dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + return Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / len; +} + +/** Convert a loop of sub-grid points to map-cell coords. */ +export function loopToCells(loop, subRes) { + return loop.map((p) => ({ x: p.x / subRes, y: p.y / subRes })); +} + +/** A loop's signed area (screen y-down): >0 clockwise (outer), <0 hole. */ +export function signedArea(loop) { + let a = 0; + for (let i = 0; i < loop.length; i += 1) { + const p = loop[i], q = loop[(i + 1) % loop.length]; + a += p.x * q.y - q.x * p.y; + } + return a / 2; +} diff --git a/module/lib/outdoor.mjs b/module/lib/outdoor.mjs new file mode 100644 index 0000000..a6263b6 --- /dev/null +++ b/module/lib/outdoor.mjs @@ -0,0 +1,112 @@ +/** + * Outdoor biome generator. + * + * Open terrain with organic features: a water body (shoreline blocks movement, + * not sight), rock outcrops (full blockers), and scattered/clustered trees + * (decoration; large ones optionally block sight). Pure / Node-testable. + * + * Coordinates in map CELLS. Organic blobs are lumpy polygons smoothed with the + * shared Chaikin routine, so shorelines and rocks have no straight edges. + */ + +import { Rng } from "./rng.mjs"; +import { chaikin } from "./organic.mjs"; + +export const BIOMES = ["grassland", "desert", "snow", "badlands"]; + +/** A smooth lumpy blob polygon centred at (cx,cy). Returns map-cell points. */ +function lumpyPolygon(cx, cy, baseR, rng, opts = {}) { + const n = opts.points ?? 12; + const jitter = opts.jitter ?? 0.42; + const squash = opts.squash ?? (0.75 + rng.float() * 0.5); + const rot = rng.float() * Math.PI * 2; + const pts = []; + for (let i = 0; i < n; i += 1) { + const a = rot + (i / n) * Math.PI * 2; + const r = baseR * (1 - jitter / 2 + rng.float() * jitter); + pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * squash }); + } + return chaikin(pts, opts.smooth ?? 2); +} + +function polySegments(points, kind) { + const segs = []; + for (let i = 0; i < points.length; i += 1) { + const a = points[i], b = points[(i + 1) % points.length]; + segs.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y, kind }); + } + return segs; +} + +function poissonScatter(rng, cols, rows, count, minDist, reject) { + const pts = []; + let guard = 0; + while (pts.length < count && guard++ < count * 40) { + const p = { x: rng.range(1, cols - 1), y: rng.range(1, rows - 1) }; + if (reject && reject(p)) continue; + if (pts.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < minDist)) continue; + pts.push(p); + } + return pts; +} + +/** + * @param {object} opts cols, rows, seed, biome, water(bool), rockCount, treeDensity, deepWater(bool) + */ +export function generateOutdoor(opts) { + const cols = opts.cols, rows = opts.rows; + const rng = new Rng(opts.seed ?? "field"); + const biome = BIOMES.includes(opts.biome) ? opts.biome : "grassland"; + const segments = []; + const features = []; + + // --- Water body (optional) ------------------------------------------------ + let water = null; + if (opts.water !== false && rng.float() < (opts.waterChance ?? 0.85)) { + const edge = rng.pick(["left", "right", "top", "bottom", "corner"]); + let cx = cols * 0.5, cy = rows * 0.5; + const r = Math.min(cols, rows) * (0.22 + rng.float() * 0.12); + if (edge === "left") { cx = cols * 0.18; cy = rows * (0.3 + rng.float() * 0.4); } + else if (edge === "right") { cx = cols * 0.82; cy = rows * (0.3 + rng.float() * 0.4); } + else if (edge === "top") { cx = cols * (0.3 + rng.float() * 0.4); cy = rows * 0.18; } + else if (edge === "bottom") { cx = cols * (0.3 + rng.float() * 0.4); cy = rows * 0.82; } + else { cx = cols * 0.8; cy = rows * 0.8; } + const loop = lumpyPolygon(cx, cy, r, rng, { points: 16, jitter: 0.5, smooth: 3 }); + water = { loop, cx, cy, r }; + if (opts.deepWater !== false) segments.push(...polySegments(loop, "water")); + features.push({ x: cx, y: cy, label: biome === "desert" ? "Oasis" : "Water" }); + } + + const inWater = (p) => water && Math.hypot(p.x - water.cx, p.y - water.cy) < water.r * 1.1; + + // --- Rock outcrops -------------------------------------------------------- + const rocks = []; + const rockCount = opts.rockCount ?? (2 + Math.floor(rng.float() * 3)); + const rockCenters = poissonScatter(rng, cols, rows, rockCount, Math.min(cols, rows) * 0.28, inWater); + for (const c of rockCenters) { + const r = Math.min(cols, rows) * (0.08 + rng.float() * 0.07); + const loop = lumpyPolygon(c.x, c.y, r, rng, { points: 10, jitter: 0.55, smooth: 2 }); + rocks.push({ loop, cx: c.x, cy: c.y, r }); + segments.push(...polySegments(loop, "wall")); + } + const nearRock = (p) => rocks.some((rk) => Math.hypot(p.x - rk.cx, p.y - rk.cy) < rk.r + 1); + + // --- Trees (clustered) ---------------------------------------------------- + const trees = []; + const density = opts.treeDensity ?? (biome === "desert" || biome === "snow" ? 0.4 : 1); + const clusterCount = Math.round((cols * rows) / 120 * density); + const clusters = poissonScatter(rng, cols, rows, clusterCount, Math.min(cols, rows) * 0.16, + (p) => inWater(p) || nearRock(p)); + for (const cl of clusters) { + const n = 2 + Math.floor(rng.float() * 5); + for (let i = 0; i < n; i += 1) { + const x = cl.x + (rng.float() - 0.5) * 2.4; + const y = cl.y + (rng.float() - 0.5) * 2.4; + if (x < 0.5 || y < 0.5 || x > cols - 0.5 || y > rows - 0.5) continue; + if (inWater({ x, y }) || nearRock({ x, y })) continue; + trees.push({ x, y, r: 0.45 + rng.float() * 0.4, shade: rng.float() }); + } + } + + return { cols, rows, biome, water, rocks, trees, segments, features }; +} diff --git a/module/lib/population.mjs b/module/lib/population.mjs new file mode 100644 index 0000000..5876de4 --- /dev/null +++ b/module/lib/population.mjs @@ -0,0 +1,75 @@ +/** + * System-agnostic map population. + * + * Works in ANY Foundry world: never assumes a game system, actors, or + * compendia. Labelled rooms/features become GM map Notes; optional ambient + * lights add atmosphere. Lightweight by design. + */ + +const MODULE_ID = "mapwright"; +const BOOK_ICON = "icons/svg/book.svg"; + +function note(x, y, text, gs, kind) { + return { + x: Math.round(x * gs), + y: Math.round(y * gs), + text, + texture: { src: BOOK_ICON }, + iconSize: Math.max(32, Math.round(gs * 0.5)), + fontSize: Math.max(16, Math.round(gs * 0.24)), + global: false, + flags: { [MODULE_ID]: { generated: true, kind } } + }; +} + +/** Notes for each labelled building room. */ +export function buildRoomNotes(layout, gs) { + const out = []; + for (const room of layout.rooms) { + if (!room.label) continue; + out.push(note(room.cx, room.cy, room.locked ? `${room.label} (locked)` : room.label, gs, "room-note")); + } + return out; +} + +/** Notes for cave/outdoor feature points ({x,y,label}). */ +export function buildFeatureNotes(features, gs) { + return (features ?? []).map((f) => note(f.x, f.y, f.label, gs, "feature-note")); +} + +/** Soft ambient light per building room. */ +export function buildRoomLights(layout, gs, subtype) { + return layout.rooms.map((room) => ({ + x: Math.round(room.cx * gs), + y: Math.round(room.cy * gs), + walls: true, + vision: false, + config: { + dim: Math.round(Math.max(room.w, room.h) * gs * 0.9), + bright: Math.round(Math.max(room.w, room.h) * gs * 0.35), + color: subtype === "fantasy" ? "#ffb46b" : "#fdf6e3", + luminosity: 0.35, + alpha: 0.35 + }, + flags: { [MODULE_ID]: { generated: true } } + })); +} + +/** A warm torch light at each cave feature point. */ +export function buildFeatureLights(features, gs, animate = true) { + return (features ?? []).map((f) => ({ + x: Math.round(f.x * gs), + y: Math.round(f.y * gs), + walls: true, + vision: false, + config: { + dim: Math.round(gs * 4), + bright: Math.round(gs * 1.6), + color: "#ff9d4a", + luminosity: 0.4, + alpha: 0.3, + animation: animate ? { type: "torch", speed: 3, intensity: 4 } : {} + }, + flags: { [MODULE_ID]: { generated: true } } + })); +} diff --git a/module/lib/render-cave.mjs b/module/lib/render-cave.mjs new file mode 100644 index 0000000..a93a307 --- /dev/null +++ b/module/lib/render-cave.mjs @@ -0,0 +1,134 @@ +/** + * Organic cave renderer: smoothed contour loops -> SVG. + * + * The look: rocky floor inside flowing walls, thick rock walls with a cast + * shadow + inner highlight, scattered floor debris, optional grid clipped to + * floor. Pure + deterministic. + */ + +import { Rng } from "./rng.mjs"; + +const THEMES = { + cave: { + outside: "#0f0d0b", floorBase: "#7d7164", floorSpeck: "#8d8173", + floorEdge: "#5e5349", wall: "#1d1813", wallHi: "#5a5042", + grid: "#3a342e", label: "#f3e9d4", speckLight: "#9a8e80" + }, + crypt: { + outside: "#0c0e12", floorBase: "#6a6f7c", floorSpeck: "#7a8090", + floorEdge: "#50545f", wall: "#171a20", wallHi: "#566173", + grid: "#33363d", label: "#eef3fb", speckLight: "#8990a0" + }, + sewer: { + outside: "#0c100e", floorBase: "#61705f", floorSpeck: "#6e7e69", + floorEdge: "#4a564a", wall: "#161d18", wallHi: "#566355", + grid: "#2f3733", label: "#e7f5ec", speckLight: "#7e8e78" + } +}; + +const PX = (v, gs) => +(v * gs).toFixed(1); + +export function renderCaveSVG(args) { + const model = args.model; + const gs = args.gridSize ?? 70; + const theme = THEMES[args.theme] ?? THEMES.cave; + const showGrid = args.showGrid !== false; + const showLabels = args.showLabels !== false; + const rng = new Rng(`${args.seed ?? "cave"}::caverender`); + + const W = model.cols * gs; + const H = model.rows * gs; + const out = []; + + const pathFor = (pts) => { + let d = `M${PX(pts[0].x, gs)},${PX(pts[0].y, gs)}`; + for (let i = 1; i < pts.length; i += 1) d += `L${PX(pts[i].x, gs)},${PX(pts[i].y, gs)}`; + return d + "Z"; + }; + const floorPath = model.loops.map((lp) => pathFor(lp.smooth)).join(" "); + + out.push(``); + + out.push(``); + out.push( + `` + + `` + ); + out.push(``); + out.push(``); + + // Rock surround. + out.push(``); + out.push(rockTexture(W, H, theme, gs, rng)); + + // Floor fill (evenodd handles pillars/holes). + out.push(``); + + // Floor detail clipped to floor. + out.push(``); + out.push(floorDebris(model, gs, theme, rng)); + if (showGrid) out.push(caveGrid(model, gs, theme.grid)); + // inner shading ring just inside the walls + out.push(``); + out.push(``); + + // Walls: cast shadow, body, inner highlight. + const wallW = (gs * 0.26).toFixed(1); + out.push(``); + out.push(``); + out.push(``); + out.push(``); + + // Feature labels. + if (showLabels && model.features?.length) { + out.push(``); + for (const f of model.features) { + const fs = Math.max(10, gs * 0.22); + out.push(``); + out.push(`${escapeXml(f.label)}`); + } + out.push(``); + } + + out.push(``); + return out.join(""); +} + +function rockTexture(W, H, theme, gs, rng) { + // Sparse rock cracks/blotches outside, very subtle. + const out = [``]; + const n = Math.round((W * H) / (gs * gs) / 6); + for (let i = 0; i < n; i += 1) { + const x = rng.float() * W, y = rng.float() * H; + const len = gs * (0.3 + rng.float() * 0.5); + const ang = rng.float() * Math.PI; + out.push(``); + } + out.push(``); + return out.join(""); +} + +function floorDebris(model, gs, theme, rng) { + const out = [``]; + const n = Math.round(model.cols * model.rows * 0.6); + for (let i = 0; i < n; i += 1) { + const x = rng.float() * model.cols, y = rng.float() * model.rows; + const r = gs * (0.02 + rng.float() * 0.06); + const col = rng.float() < 0.5 ? theme.floorSpeck : theme.speckLight; + out.push(``); + } + out.push(``); + return out.join(""); +} + +function caveGrid(model, gs, color) { + const out = [``]; + for (let x = 0; x <= model.cols; x += 1) out.push(``); + for (let y = 0; y <= model.rows; y += 1) out.push(``); + out.push(``); + return out.join(""); +} + +function escapeXml(str) { + return String(str).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c])); +} diff --git a/module/lib/render-outdoor.mjs b/module/lib/render-outdoor.mjs new file mode 100644 index 0000000..fff9cca --- /dev/null +++ b/module/lib/render-outdoor.mjs @@ -0,0 +1,155 @@ +/** + * Outdoor biome renderer: grass/sand/snow base, water with ripples, rock + * outcrops, layered tree canopies with shadows. Pure + deterministic. + */ + +import { Rng } from "./rng.mjs"; + +const BIOME = { + grassland: { + ground: "#5b7a3a", groundAlt: "#6b8a44", tuft: "#46602c", patch: "#52722f", + water: "#3f6f86", waterHiSafe: "#5a8caa", waterEdge: "#2c5468", + rock: "#7d7a73", rockHi: "#9a968d", rockShadow: "#4a473f", + tree: "#2f5526", treeHi: "#3f6e30", treeShadow: "#1d3318", + grid: "#3c5226", label: "#f3f7e6" + }, + desert: { + ground: "#c9a96a", groundAlt: "#d4b676", tuft: "#b3954f", patch: "#bf9f5d", + water: "#4f8f9e", waterHiSafe: "#74b3bf", waterEdge: "#386b78", + rock: "#a98f63", rockHi: "#c2a877", rockShadow: "#6e5a39", + tree: "#5a6e3a", treeHi: "#6f8347", treeShadow: "#3a4825", + grid: "#9b8048", label: "#3a2c12" + }, + snow: { + ground: "#dfe6ec", groundAlt: "#eef3f7", tuft: "#c2ccd4", patch: "#d2dbe2", + water: "#6f97ad", waterHiSafe: "#9bbdcd", waterEdge: "#557e94", + rock: "#8b9099", rockHi: "#a7adb6", rockShadow: "#565b63", + tree: "#3c5847", treeHi: "#4e6f59", treeShadow: "#26382d", + grid: "#aebac4", label: "#2a323a" + }, + badlands: { + ground: "#9c6a44", groundAlt: "#a9754d", tuft: "#824f30", patch: "#90613b", + water: "#5a7d6e", waterHiSafe: "#7ea293", waterEdge: "#415d51", + rock: "#8a5a3c", rockHi: "#a87149", rockShadow: "#5a3824", + tree: "#6a6232", treeHi: "#7e763f", treeShadow: "#453f1f", + grid: "#6e4628", label: "#f6e9da" + } +}; + +const PX = (v, gs) => +(v * gs).toFixed(1); + +function pathFor(pts, gs) { + let d = `M${PX(pts[0].x, gs)},${PX(pts[0].y, gs)}`; + for (let i = 1; i < pts.length; i += 1) d += `L${PX(pts[i].x, gs)},${PX(pts[i].y, gs)}`; + return d + "Z"; +} + +export function renderOutdoorSVG(args) { + const m = args.model; + const gs = args.gridSize ?? 70; + const t = BIOME[m.biome] ?? BIOME.grassland; + const showGrid = args.showGrid !== false; + const showLabels = args.showLabels !== false; + const rng = new Rng(`${args.seed ?? "field"}::outrender`); + const W = m.cols * gs, H = m.rows * gs; + const out = []; + + out.push(``); + out.push(`` + + `` + + `` + + ``); + + // Ground base + soft colour patches + tufts. + out.push(``); + out.push(groundPatches(m, gs, t, rng)); + if (showGrid) out.push(grid(m, gs, t.grid)); + out.push(tufts(m, gs, t, rng)); + + // Water. + if (m.water) { + out.push(``); + out.push(ripples(m.water, gs, t, rng)); + } + + // Rocks. + for (const rk of m.rocks) { + out.push(``); + out.push(``); + out.push(``); + // a couple of highlight facets + out.push(``); + } + + // Trees (sorted by y for overlap depth). + const trees = m.trees.slice().sort((a, b) => a.y - b.y); + out.push(``); + for (const tr of trees) { + const cx = PX(tr.x, gs), cy = PX(tr.y, gs), r = tr.r * gs; + out.push(``); + out.push(``); + } + out.push(``); + + // Feature labels. + if (showLabels && m.features?.length) { + out.push(``); + for (const f of m.features) { + out.push(`${escapeXml(f.label)}`); + } + out.push(``); + } + + out.push(``); + return out.join(""); +} + +function groundPatches(m, gs, t, rng) { + const out = [``]; + const n = Math.round(m.cols * m.rows / 14); + for (let i = 0; i < n; i += 1) { + const x = rng.float() * m.cols, y = rng.float() * m.rows; + const rx = (0.8 + rng.float() * 2.2) * gs, ry = rx * (0.6 + rng.float() * 0.5); + const col = rng.float() < 0.5 ? t.groundAlt : t.patch; + out.push(``); + } + out.push(``); + return out.join(""); +} + +function tufts(m, gs, t, rng) { + const out = [``]; + const n = Math.round(m.cols * m.rows * 1.1); + for (let i = 0; i < n; i += 1) { + const x = PX(rng.float() * m.cols, gs), y = PX(rng.float() * m.rows, gs); + const h = gs * (0.06 + rng.float() * 0.08); + out.push(``); + } + out.push(``); + return out.join(""); +} + +function ripples(water, gs, t, rng) { + const out = [``]; + const n = 8; + for (let i = 0; i < n; i += 1) { + const x = water.cx + (rng.float() - 0.5) * water.r * 1.2; + const y = water.cy + (rng.float() - 0.5) * water.r * 1.2; + const w = gs * (0.3 + rng.float() * 0.5); + out.push(``); + } + out.push(``); + return out.join(""); +} + +function grid(m, gs, color) { + const out = [``]; + for (let x = 0; x <= m.cols; x += 1) out.push(``); + for (let y = 0; y <= m.rows; y += 1) out.push(``); + out.push(``); + return out.join(""); +} + +function escapeXml(str) { + return String(str).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c])); +} diff --git a/module/lib/render-settlement.mjs b/module/lib/render-settlement.mjs new file mode 100644 index 0000000..bc0318d --- /dev/null +++ b/module/lib/render-settlement.mjs @@ -0,0 +1,267 @@ +/** + * Settlement renderer — detailed top-down village / city-block tactical maps. + * Rich, colourful baked-in scenery (cars, trees, lamps, crosswalks, market + * stalls, wells, flower beds…). Buildings are rooftop blocks; streets/grass are + * the open token-scaled play space. Pure + deterministic. + */ + +import { Rng } from "./rng.mjs"; + +const CITY = { + asphalt: "#33373d", asphaltAlt: "#2c3035", sidewalk: "#b3bac2", + curb: "#7f868e", laneYellow: "#e8c64a", laneWhite: "#d7dde3", + grid: "#3c4954", roofTrim: "#23262b", park: "#5d7c3c", parkPath: "#b9a988", label: "#eef3f7" +}; +const VILLAGE = { + grass: "#5f7e3e", grassAlt: "#6e8d48", tuft: "#47602c", road: "#9c814f", roadEdge: "#6f5a34", + cobble: "#8c7448", water: "#3f6f86", waterEdge: "#2c5468", square: "#b7a079", + garden: ["#6b8a3a", "#8a7a3a", "#5a7b34"], gardenRow: "#42501f", fence: "#6a4a2c", + grid: "#42562a", roofTrim: "#2a1d12", label: "#f3f7e6" +}; +const TREE_GREENS = [["#2f5526", "#3f6e30"], ["#355e2a", "#4a7a38"], ["#2a4a22", "#386026"]]; + +const PX = (v, gs) => +(v * gs).toFixed(1); + +export function renderSettlementSVG(args) { + const m = args.model; + const gs = args.gridSize ?? 100; + const showGrid = args.showGrid !== false; + const showLabels = args.showLabels !== false; + const rng = new Rng(`${args.seed ?? "town"}::settle`); + const W = m.cols * gs, H = m.rows * gs; + const out = [``]; + + if (m.style === "city") renderCity(out, m, gs, W, H, rng); + else renderVillage(out, m, gs, W, H, rng); + + if (showGrid) out.push(grid(m, gs, m.style === "city" ? CITY.grid : VILLAGE.grid)); + if (showLabels && m.features?.length) { + out.push(``); + for (const f of m.features) out.push(`${esc(f.label)}`); + out.push(``); + } + out.push(``); + return out.join(""); +} + +// ============================ CITY ======================================== +function renderCity(out, m, gs, W, H, rng) { + out.push(``); + out.push(speckle(W, H, gs, "#000", 0.07, 4, rng)); + + // crosswalks (under sidewalks/markings) + out.push(``); + for (const cw of m.crosswalks) out.push(crosswalk(cw, gs)); + out.push(``); + + // lane markings: solid white edge + dashed yellow centre + out.push(``); + for (const ln of m.roadLines) out.push(laneLine(ln, gs, W, H, 0.55)); + for (const ln of m.roadLines) out.push(laneLine(ln, gs, W, H, -0.55)); + out.push(``); + out.push(``); + for (const ln of m.roadLines) out.push(laneLine(ln, gs, W, H, 0)); + out.push(``); + + // sidewalks + for (const blk of m.blocks) { + const x = PX(blk.x, gs), y = PX(blk.y, gs), w = PX(blk.w, gs), h = PX(blk.h, gs); + out.push(``); + } + // sidewalk paving grid (subtle) + out.push(``); + for (const blk of m.blocks) for (let i = 1; i < blk.w; i += 1) out.push(``); + out.push(``); + + // parks + for (const p of m.parks) out.push(park(p, gs, rng)); + + // cars (under buildings so buildings cast over them slightly; fine on road) + out.push(``); + for (const c of m.cars) out.push(car(c, gs)); + out.push(``); + + // road props that sit on asphalt (manhole/patch) + for (const p of m.props) if (p.kind === "manhole" || p.kind === "patch") out.push(prop(p, gs, rng)); + + // buildings + for (const b of m.buildings) cityRoof(out, b, gs, rng); + + // sidewalk props (above buildings' shadow, on sidewalk) + for (const p of m.props) if (!(p.kind === "manhole" || p.kind === "patch")) out.push(prop(p, gs, rng)); +} + +function crosswalk(cw, gs) { + let s = ""; + if (cw.vertical) { + for (let i = 0; i < cw.w; i += 1) if (i % 2 === 0) s += ``; + } else { + for (let i = 0; i < cw.h; i += 1) if (i % 2 === 0) s += ``; + } + return s; +} + +function laneLine(ln, gs, W, H, off) { + if (ln.vertical) { const x = PX(ln.at + off, gs); return ``; } + const y = PX(ln.at + off, gs); return ``; +} + +function cityRoof(out, b, gs, rng) { + const x = PX(b.x, gs), y = PX(b.y, gs), w = PX(b.w, gs), h = PX(b.h, gs), off = gs * 0.1; + out.push(``); + out.push(``); + // inner trim + out.push(``); + const cx = b.x + b.w / 2, cy = b.y + b.h / 2; + if (b.roofStyle === 1 && b.w >= 3 && b.h >= 3) { // rooftop garden + out.push(``); + } else if (b.roofStyle === 2) { // water tank + out.push(``); + } else { // AC units / skylights + const units = Math.min(4, Math.max(1, Math.floor(b.w * b.h / 5))); + for (let i = 0; i < units; i += 1) { + const ux = PX(b.x + 0.3 + rng.float() * (b.w - 0.8), gs), uy = PX(b.y + 0.3 + rng.float() * (b.h - 0.8), gs), us = gs * (0.22 + rng.float() * 0.16); + out.push(``); + } + } +} + +function car(c, gs) { + const long = gs * 1.7, wide = gs * 0.8; + const w = c.horizontal ? long : wide, h = c.horizontal ? wide : long; + const x = PX(c.x, gs) - w / 2, y = PX(c.y, gs) - h / 2; + const wx = c.horizontal ? w * 0.32 : w, wy = c.horizontal ? h : h * 0.32; + return `` + + `` + + `` + + `` + + ``; +} + +function park(p, gs, rng) { + const x = PX(p.x, gs), y = PX(p.y, gs), w = PX(p.w, gs), h = PX(p.h, gs); + let s = ``; + // diagonal path + s += ``; + s += ``; + const n = Math.max(2, Math.floor(p.w * p.h / 4)); + for (let i = 0; i < n; i += 1) s += treeSVG(p.x + 0.5 + rng.float() * (p.w - 1), p.y + 0.5 + rng.float() * (p.h - 1), 0.5 + rng.float() * 0.3, 0, gs); + return s; +} + +function prop(p, gs, rng) { + const cx = PX(p.x, gs), cy = PX(p.y, gs); + switch (p.kind) { + case "tree": return treeSVG(p.x, p.y, 0.42 + (p.x % 1) * 0.001 + 0.16, 1, gs); + case "lamp": return ``; + case "bench": return ``; + case "hydrant": return ``; + case "planter": return ``; + case "manhole": return ``; + case "patch": return ``; + // village props + case "well": return ``; + case "fountain": return ``; + case "stall": return ``; + case "cart": return ``; + case "barrel": return ``; + case "haybale": return ``; + case "bush": return ``; + case "flowers": return ``; + default: return ""; + } +} + +// ============================ VILLAGE ===================================== +function renderVillage(out, m, gs, W, H, rng) { + out.push(``); + out.push(grassPatches(m, gs, rng)); + out.push(grassTufts(m, gs, rng)); + + if (m.road?.length) { + const d = "M" + m.road.map((p) => `${PX(p.x, gs)},${PX(p.y, gs)}`).join(" L"); + const wpx = (m.roadHalf * 2 * gs); + out.push(``); + out.push(``); + out.push(``); + } + if (m.square) out.push(``); + + if (m.water) { + const d = "M" + m.water.loop.map((p) => `${PX(p.x, gs)},${PX(p.y, gs)}`).join(" L") + "Z"; + out.push(``); + } + + for (const p of m.plots) { + const x = PX(p.x, gs), y = PX(p.y, gs), w = PX(p.w, gs), h = PX(p.h, gs); + out.push(``); + for (let r = 0; r < p.h * 2; r += 1) out.push(``); + } + + // ground props that sit under buildings (well/fountain/stall/cart on square/grass) + for (const p of m.props) if (p.kind !== "bush" && p.kind !== "flowers") out.push(prop(p, gs, rng)); + + for (const b of m.buildings) villageRoof(out, b, gs); + + // trees + small props above buildings + const trees = m.trees.slice().sort((a, b) => a.y - b.y); + out.push(``); + for (const t of trees) out.push(treeSVG(t.x, t.y, t.r, t.green ?? 0, gs)); + out.push(``); + for (const p of m.props) if (p.kind === "bush" || p.kind === "flowers") out.push(prop(p, gs, rng)); +} + +function villageRoof(out, b, gs) { + const x = PX(b.x, gs), y = PX(b.y, gs), w = PX(b.w, gs), h = PX(b.h, gs), off = gs * 0.1; + out.push(``); + out.push(``); + // ridge line + thatch/tile rows + if (b.w >= b.h) { + const my = y + h / 2; + out.push(``); + for (let i = 1; i < b.w; i += 1) out.push(``); + } else { + const mx = x + w / 2; + out.push(``); + for (let i = 1; i < b.h; i += 1) out.push(``); + } + if (b.chimney) out.push(``); +} + +// ============================ shared ====================================== +function treeSVG(x, y, r, greenIdx, gs) { + const cx = PX(x, gs), cy = PX(y, gs), rr = r * gs, g = TREE_GREENS[greenIdx % TREE_GREENS.length]; + return `` + + `` + + ``; +} + +function speckle(W, H, gs, color, opacity, density, rng) { + const out = [``]; + const n = Math.round((W * H) / (gs * gs) / density); + for (let i = 0; i < n; i += 1) { const x = rng.float() * W, y = rng.float() * H, l = gs * (0.2 + rng.float() * 0.4), a = rng.float() * Math.PI; out.push(``); } + return out.join("") + ``; +} + +function grassPatches(m, gs, rng) { + const out = [``]; + const n = Math.round(m.cols * m.rows / 14); + for (let i = 0; i < n; i += 1) { const x = rng.float() * m.cols, y = rng.float() * m.rows, rx = (0.8 + rng.float() * 2) * gs; out.push(``); } + return out.join("") + ``; +} + +function grassTufts(m, gs, rng) { + const out = [``]; + const n = Math.round(m.cols * m.rows * 0.8); + for (let i = 0; i < n; i += 1) { const x = PX(rng.float() * m.cols, gs), y = PX(rng.float() * m.rows, gs), hh = gs * (0.05 + rng.float() * 0.07); out.push(``); } + return out.join("") + ``; +} + +function grid(m, gs, color) { + const out = [``]; + for (let x = 0; x <= m.cols; x += 1) out.push(``); + for (let y = 0; y <= m.rows; y += 1) out.push(``); + return out.join("") + ``; +} + +function esc(s) { return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c])); } diff --git a/module/lib/render-vector.mjs b/module/lib/render-vector.mjs new file mode 100644 index 0000000..e7b4278 --- /dev/null +++ b/module/lib/render-vector.mjs @@ -0,0 +1,354 @@ +/** + * Procedural-vector renderer: layout + segments -> SVG string. + * + * No external art. The "good look" comes from established cartography tricks: + * - thick walls with a soft cast shadow onto the floor (reads as 3-D) + * - Dyson-style hatching just outside the exterior walls + * - per-theme floor texture drawn in SVG (tiles / flagstones / boards) + * - architectural door symbols (leaf + swing arc) + * + * Pure + deterministic: same seed -> same decoration. Runs in Node and browser. + */ + +import { Rng } from "./rng.mjs"; +import { pieceInnerSVG } from "./furniture.mjs"; + +const THEMES = { + modern: { + outside: "#23262b", floorBase: "#d9dde2", floorSeam: "#b3bbc4", + grid: "#9aa3ad", wall: "#2b2f36", wallTop: "#3c424b", + doorFrame: "#3a3f45", doorLeaf: "#1f2329", doorFill: "#5a6470", + window: "#8fc4e2", windowFrame: "#3a3f45", + label: "#2b2f36", hatch: "#3a3f45", floorStyle: "tile" + }, + fantasy: { + outside: "#2a241c", floorBase: "#bcab8f", floorSeam: "#8d7a5c", + grid: "#6f5f47", wall: "#372d20", wallTop: "#4c3f2c", + doorFrame: "#6a4a2c", doorLeaf: "#3a2614", doorFill: "#7a5532", + window: "#86b8c4", windowFrame: "#4a3a26", + label: "#322617", hatch: "#4a3a26", floorStyle: "flagstone" + } +}; + +const PX = (v, gs) => +(v * gs).toFixed(1); + +/** + * @param {object} args + * @param {object} args.layout generateBuilding() output + * @param {object} args.walls buildWalls() output ({segments,...}) + * @param {number} [args.gridSize] + * @param {string} [args.subtype] "modern" | "fantasy" + * @param {boolean} [args.showGrid] + * @param {boolean} [args.showLabels] + * @param {string|number} [args.seed] + * @returns {string} SVG markup + */ +export function renderSVG(args) { + const { layout } = args; + const gs = args.gridSize ?? 70; + const theme = THEMES[args.subtype] ? THEMES[args.subtype] : THEMES.modern; + const showGrid = args.showGrid !== false; + const showLabels = args.showLabels !== false; + const rng = new Rng(`${args.seed ?? "deco"}::render`); + + const W = layout.cols * gs; + const H = layout.rows * gs; + const subtype = THEMES[args.subtype] ? args.subtype : "modern"; + const region = layout.region; + const cols = layout.cols, rows = layout.rows; + const showFurniture = args.showFurniture !== false; + + const segs = args.walls.segments; + const out = []; + const floorPath = buildFloorPath(region, cols, rows, gs); + + out.push(``); + + // --- defs: shadow filter + clip to floor --------------------------------- + out.push(``); + out.push( + `` + + `` + + `` + ); + out.push(``); + out.push(``); + + // --- outside ground ------------------------------------------------------ + out.push(``); + + // --- exterior hatching (drawn under the floor so it tucks beneath walls) -- + out.push(``); + out.push(exteriorHatching(segs, region, cols, rows, gs, rng)); + out.push(``); + + // --- floor base + texture + furniture (clipped to footprint) ------------- + out.push(``); + out.push(``); + out.push(floorTexture(theme, layout, gs, rng)); + if (showGrid) out.push(gridOverlay(layout, gs, theme.grid)); + if (showFurniture && args.placements?.length) out.push(drawPlacements(args.placements, subtype)); + out.push(``); + + // --- walls (with cast shadow) -------------------------------------------- + const wallW = (gs * 0.17).toFixed(1); + out.push(``); + out.push(``); + for (const s of segs) { + if (s.kind !== "wall") continue; + out.push(``); + } + out.push(``); + + // --- doors + windows (era-aware artwork) --------------------------------- + for (const s of segs) { + if (s.kind === "door") out.push(doorSymbol(s, gs, theme, subtype)); + else if (s.kind === "window") out.push(windowSymbol(s, gs, theme, gs * 0.17, subtype)); + } + + // --- room labels --------------------------------------------------------- + if (showLabels) { + out.push(``); + for (const room of layout.rooms) { + if (!room.label) continue; + const fontSize = Math.max(8, Math.min(gs * 0.22, room.w * gs * 0.3)); + const ly = PX(room.cy, gs) - (room.h * gs) * 0.3; + out.push( + `${escapeXml(room.label.toUpperCase())}` + ); + } + out.push(``); + } + + out.push(``); + return out.join(""); +} + +/** Draw furniture placements (same geometry the locked Tiles use). */ +function drawPlacements(placements, subtype) { + let out = ``; + for (const p of placements) { + const inner = pieceInnerSVG(p.kind, subtype, p.wpx, p.hpx); + const tx = (p.cx - p.wpx / 2).toFixed(1); + const ty = (p.cy - p.hpx / 2).toFixed(1); + out += `${inner}`; + } + return out + ``; +} + +/** Floor path from the region grid: per-row runs of in-room cells -> rects. */ +function buildFloorPath(region, cols, rows, gs) { + let d = ""; + for (let y = 0; y < rows; y += 1) { + let x = 0; + while (x < cols) { + if (region[y][x] < 0) { x += 1; continue; } + let run = 0; + while (x + run < cols && region[y][x + run] >= 0) run += 1; + d += `M${PX(x, gs)},${PX(y, gs)}h${PX(run, gs)}v${PX(1, gs)}h${PX(-run, gs)}Z`; + x += run; + } + } + return d; +} + +function regAt(region, x, y, cols, rows) { + if (x < 0 || y < 0 || x >= cols || y >= rows) return -1; + return region[y][x]; +} + +// --- floor textures -------------------------------------------------------- +function floorTexture(theme, layout, gs, rng) { + if (theme.floorStyle === "flagstone") return flagstoneTexture(theme, layout, gs, rng); + return tileTexture(theme, layout, gs); +} + +function tileTexture(theme, layout, gs) { + // Clean square-tile seams across the footprint. + const fp = layout.footprint; + const lines = []; + lines.push(``); + for (let x = fp.x; x <= fp.x + fp.w; x += 1) { + lines.push(``); + } + for (let y = fp.y; y <= fp.y + fp.h; y += 1) { + lines.push(``); + } + lines.push(``); + return lines.join(""); +} + +function flagstoneTexture(theme, layout, gs, rng) { + // Irregular flagstones: per in-room cell, a jittered quad. (Clipped to floor.) + const { region, cols, rows } = layout; + const j = gs * 0.14; + const out = [``]; + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + if (region[y][x] < 0) continue; + const p = (cx, cy) => `${PX(cx, gs) + (rng.float() - 0.5) * j},${PX(cy, gs) + (rng.float() - 0.5) * j}`; + out.push(``); + } + } + out.push(``); + return out.join(""); +} + +function gridOverlay(layout, gs, color) { + const fp = layout.footprint; + const out = [``]; + for (let x = fp.x; x <= fp.x + fp.w; x += 1) { + out.push(``); + } + for (let y = fp.y; y <= fp.y + fp.h; y += 1) { + out.push(``); + } + out.push(``); + return out.join(""); +} + +// --- exterior hatching ----------------------------------------------------- +function exteriorHatching(segs, region, cols, rows, gs, rng) { + const out = []; + const step = 0.4; // along-wall spacing in cells + const len = gs * 0.34; // tick length in px + for (const s of segs) { + if (s.kind !== "wall") continue; + const isV = s.x1 === s.x2; + // Detect exterior + outward direction by sampling either side at the midpoint. + let nx = 0, ny = 0, exterior = false; + if (isV) { + const X = s.x1, midY = Math.floor((s.y1 + s.y2) / 2); + const left = regAt(region, X - 1, midY, cols, rows); + const right = regAt(region, X, midY, cols, rows); + if (left === -1 && right >= 0) { nx = -1; exterior = true; } + else if (right === -1 && left >= 0) { nx = 1; exterior = true; } + } else { + const Y = s.y1, midX = Math.floor((s.x1 + s.x2) / 2); + const top = regAt(region, midX, Y - 1, cols, rows); + const bottom = regAt(region, midX, Y, cols, rows); + if (top === -1 && bottom >= 0) { ny = -1; exterior = true; } + else if (bottom === -1 && top >= 0) { ny = 1; exterior = true; } + } + if (!exterior) continue; + const length = isV ? (s.y2 - s.y1) : (s.x2 - s.x1); + for (let t = step / 2; t < length; t += step) { + const bx = isV ? s.x1 : s.x1 + t; + const by = isV ? s.y1 + t : s.y1; + const px = PX(bx, gs), py = PX(by, gs); + // slight slant for a hand-drawn feel + const slant = (rng.float() - 0.5) * 0.5; + const ex = px + nx * len + (isV ? 0 : slant * len); + const ey = py + ny * len + (isV ? slant * len : 0); + out.push(``); + } + } + return out.join(""); +} + +// --- door symbol (era-aware artwork) --------------------------------------- +function doorSymbol(s, gs, theme, subtype) { + const x1 = PX(s.x1, gs), y1 = PX(s.y1, gs); + const x2 = PX(s.x2, gs), y2 = PX(s.y2, gs); + const L = Math.hypot(x2 - x1, y2 - y1) || 1; + const ux = (x2 - x1) / L, uy = (y2 - y1) / L; // along the wall + const ppx = -uy, ppy = ux; // wall perpendicular + const isV = s.orient === "v"; + const insideA = s.inside === "a"; + let nx = 0, ny = 0; + if (isV) nx = insideA ? -1 : 1; else ny = insideA ? -1 : 1; + + const wallTh = gs * 0.17; + const out = [``]; + + // Jamb posts (door frame) straddling the wall at both ends. + const jamb = wallTh * 1.25; + for (const [ex, ey] of [[x1, y1], [x2, y2]]) { + out.push( + `` + ); + } + + // Hinge at end 1; leaf swings ~75° toward the inside. + const hx = x1, hy = y1; + const ang = (75 * Math.PI) / 180; + const sign = (ux * ny - uy * nx) >= 0 ? 1 : -1; + const cos = Math.cos(ang * sign), sin = Math.sin(ang * sign); + const leafLen = L * 0.94; + const dx = ux * cos - uy * sin, dy = ux * sin + uy * cos; // leaf direction + const lx = hx + dx * leafLen, ly = hy + dy * leafLen; + const ox = hx + ux * leafLen, oy = hy + uy * leafLen; // closed position + + // Swing arc. + out.push( + `` + ); + + // Leaf as a filled panel (planked wood / metal slab). + const pdx = -dy, pdy = dx; // across the leaf thickness + const th = gs * 0.13; + const pt = (bx, by, sx, sy) => `${(bx + pdx * sx + dx * sy).toFixed(1)},${(by + pdy * sx + dy * sy).toFixed(1)}`; + out.push( + `` + ); + if (subtype === "fantasy") { + for (const f of [0.33, 0.66]) { + out.push(``); + } + } else { + out.push(``); + } + // Handle + hinge dot. + const handle = pt(hx, hy, 0, leafLen * 0.82).split(","); + out.push(``); + out.push(``); + + if (s.locked) { + const mx = (hx + ox) / 2, my = (hy + oy) / 2; + out.push( + `` + + `` + ); + } + out.push(``); + return out.join(""); +} + +// --- window symbol (era-aware artwork) ------------------------------------- +function windowSymbol(s, gs, theme, sill, subtype) { + const x1 = PX(s.x1, gs), y1 = PX(s.y1, gs), x2 = PX(s.x2, gs), y2 = PX(s.y2, gs); + const L = Math.hypot(x2 - x1, y2 - y1) || 1; + const ux = (x2 - x1) / L, uy = (y2 - y1) / L; + const px = -uy, py = ux; // perpendicular (sill width) + const out = [``]; + + // Frame band, then glass band. + out.push(``); + out.push(``); + // Highlight streak. + out.push(``); + + const bar = (f) => { + const bx = x1 + (x2 - x1) * f, by = y1 + (y2 - y1) * f; + out.push(``); + }; + if (subtype === "fantasy") { + bar(0.33); bar(0.66); // leaded panes + } else { + bar(0.5); // single mullion + } + out.push(``); + return out.join(""); +} + +function escapeXml(str) { + return String(str).replace(/[<>&'"]/g, (c) => ( + { "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c] + )); +} diff --git a/module/lib/rng.mjs b/module/lib/rng.mjs new file mode 100644 index 0000000..ba3e885 --- /dev/null +++ b/module/lib/rng.mjs @@ -0,0 +1,88 @@ +/** + * Seeded pseudo-random number generation. + * + * Pure, dependency-free, and Foundry-agnostic so it can be unit-tested in Node. + * Same seed string always produces the same map. + */ + +/** Hash an arbitrary string into a 32-bit unsigned integer seed (xfnv1a). */ +export function hashSeed(input) { + const str = String(input ?? ""); + let h = 2166136261 >>> 0; + for (let i = 0; i < str.length; i += 1) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619); + } + // Final avalanche so similar strings diverge. + h += h << 13; h ^= h >>> 7; + h += h << 3; h ^= h >>> 17; + h += h << 5; + return h >>> 0; +} + +/** mulberry32 — small, fast, good-enough PRNG. Returns a function -> float in [0,1). */ +export function mulberry32(seed) { + let a = seed >>> 0; + return function next() { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Convenience wrapper around a raw float generator with the helpers a + * generator actually needs. + */ +export class Rng { + constructor(seed) { + this._next = mulberry32(typeof seed === "number" ? seed : hashSeed(seed)); + } + + /** Float in [0, 1). */ + float() { + return this._next(); + } + + /** Float in [min, max). */ + range(min, max) { + return min + this._next() * (max - min); + } + + /** Integer in [min, max] inclusive. */ + int(min, max) { + return Math.floor(min + this._next() * (max - min + 1)); + } + + /** True with probability p (0..1). */ + chance(p) { + return this._next() < p; + } + + /** Pick a uniformly random element from a non-empty array. */ + pick(arr) { + return arr[Math.floor(this._next() * arr.length)]; + } + + /** Pick by weight. items: [{weight, ...}] -> returns one item. */ + weighted(items) { + const total = items.reduce((sum, it) => sum + (it.weight ?? 1), 0); + let roll = this._next() * total; + for (const it of items) { + roll -= it.weight ?? 1; + if (roll < 0) return it; + } + return items[items.length - 1]; + } + + /** In-place Fisher–Yates shuffle, returns the same array. */ + shuffle(arr) { + for (let i = arr.length - 1; i > 0; i -= 1) { + const j = Math.floor(this._next() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; + } +} diff --git a/module/lib/scene.mjs b/module/lib/scene.mjs new file mode 100644 index 0000000..faa2437 --- /dev/null +++ b/module/lib/scene.mjs @@ -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} + */ +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"; +} diff --git a/module/lib/settlement.mjs b/module/lib/settlement.mjs new file mode 100644 index 0000000..d91e41a --- /dev/null +++ b/module/lib/settlement.mjs @@ -0,0 +1,300 @@ +/** + * Settlement generator — detailed top-down town/village and city-block maps. + * + * Buildings are SOLID rooftop blocks (perimeter walls block tokens); streets / + * grass are the open, token-scaled play space. Generates dense building + * subdivisions plus lots of scenery (cars, trees, lamps, crosswalks, market + * stalls, wells, flower beds…) baked into the render. Pure / Node-testable. + * + * Model (fields vary by style): + * { cols, rows, style, roadCells:Set, roadLines, blocks, buildings, + * parks, crosswalks, cars, props, plots, trees, water, features, segments } + */ + +import { Rng } from "./rng.mjs"; +import { chaikin } from "./organic.mjs"; + +export const SETTLEMENT_STYLES = ["village", "city"]; + +// Colourful roof palettes. +const CITY_ROOFS = [ + "#6b7077", "#7a6a5e", "#566b7a", "#8a5b4a", "#5e7a5e", "#7a7488", + "#4a6b78", "#9c7a4a", "#6a5570", "#aa6a52", "#4f7a6a", "#86563f" +]; +const VILLAGE_ROOFS = [ + "#9c4b34", "#b5673a", "#c79a52", "#7a5236", "#6f8a4a", "#8a8f5a", + "#a85a40", "#5f7a8a", "#9a7a3a" +]; + +const key = (x, y) => `${x},${y}`; + +function rectPerimeterWalls(b) { + return [ + { x1: b.x, y1: b.y, x2: b.x + b.w, y2: b.y, kind: "wall" }, + { x1: b.x + b.w, y1: b.y, x2: b.x + b.w, y2: b.y + b.h, kind: "wall" }, + { x1: b.x + b.w, y1: b.y + b.h, x2: b.x, y2: b.y + b.h, kind: "wall" }, + { x1: b.x, y1: b.y + b.h, x2: b.x, y2: b.y, kind: "wall" } + ]; +} + +export function generateSettlement(opts) { + return opts.style === "city" ? generateCity(opts) : generateVillage(opts); +} + +// --- City block ------------------------------------------------------------ +function strips(total, roadW, bMin, bMax, rng) { + const out = [{ type: "road", a: 0, b: roadW }]; + let x = roadW; + while (x < total - 2) { + const bw = Math.min(rng.int(bMin, bMax), total - x); + if (bw < 3) { out[out.length - 1].b = total; break; } + out.push({ type: "block", a: x, b: x + bw }); x += bw; + if (x >= total) break; + const rw = Math.min(roadW, total - x); + out.push({ type: "road", a: x, b: x + rw }); x += rw; + } + if (out[out.length - 1].b < total) out.push({ type: "road", a: out[out.length - 1].b, b: total }); + return out; +} + +/** Recursively split a buildable rect into many footprints with 1-cell alleys. */ +function subdivide(rect, rng, depth, out) { + const small = rect.w < 5 && rect.h < 5; + if (depth <= 0 || small || (rect.w < 8 && rect.h < 8 && rng.chance(0.45))) { + if (rect.w >= 2 && rect.h >= 2) out.push(rect); + return; + } + const splitV = rect.w >= rect.h ? rect.w >= 5 : !(rect.h >= 5); + if (splitV && rect.w >= 5) { + const cut = rect.x + Math.floor(rect.w * (0.38 + rng.float() * 0.24)); + subdivide({ x: rect.x, y: rect.y, w: cut - rect.x, h: rect.h }, rng, depth - 1, out); + subdivide({ x: cut + 1, y: rect.y, w: rect.x + rect.w - (cut + 1), h: rect.h }, rng, depth - 1, out); + } else if (rect.h >= 5) { + const cut = rect.y + Math.floor(rect.h * (0.38 + rng.float() * 0.24)); + subdivide({ x: rect.x, y: rect.y, w: rect.w, h: cut - rect.y }, rng, depth - 1, out); + subdivide({ x: rect.x, y: cut + 1, w: rect.w, h: rect.y + rect.h - (cut + 1) }, rng, depth - 1, out); + } else if (rect.w >= 2 && rect.h >= 2) out.push(rect); +} + +function generateCity(opts) { + const cols = opts.cols, rows = opts.rows; + const rng = new Rng(opts.seed ?? "city"); + const roadW = opts.roadWidth ?? 3; + const colStrips = strips(cols, roadW, 8, 12, rng); + const rowStrips = strips(rows, roadW, 8, 12, rng); + + const roadCells = new Set(); + const isColBlock = (x) => colStrips.find((s) => x >= s.a && x < s.b)?.type === "block"; + const isRowBlock = (y) => rowStrips.find((s) => y >= s.a && y < s.b)?.type === "block"; + for (let y = 0; y < rows; y += 1) for (let x = 0; x < cols; x += 1) if (!(isColBlock(x) && isRowBlock(y))) roadCells.add(key(x, y)); + + const blocks = [], buildings = [], parks = [], segments = []; + const sidewalk = 1; + const buildingCells = new Set(); + + for (const cs of colStrips.filter((s) => s.type === "block")) { + for (const rs of rowStrips.filter((s) => s.type === "block")) { + const block = { x: cs.a, y: rs.a, w: cs.b - cs.a, h: rs.b - rs.a }; + blocks.push(block); + const inner = { x: block.x + sidewalk, y: block.y + sidewalk, w: block.w - sidewalk * 2, h: block.h - sidewalk * 2 }; + if (inner.w < 2 || inner.h < 2) continue; + if (rng.chance(0.16)) { parks.push(inner); continue; } // green space + const parts = []; + subdivide(inner, rng, 3, parts); + for (const p of parts) { + if (p.w < 2 || p.h < 2) continue; + const b = { ...p, roof: rng.pick(CITY_ROOFS), kind: "city", roofStyle: rng.int(0, 3) }; + buildings.push(b); + segments.push(...rectPerimeterWalls(b)); + for (let yy = p.y; yy < p.y + p.h; yy += 1) for (let xx = p.x; xx < p.x + p.w; xx += 1) buildingCells.add(key(xx, yy)); + } + } + } + + // Scenery: crosswalks, cars, sidewalk props. + const crosswalks = buildCrosswalks(colStrips, rowStrips); + const cars = [], props = []; + const sidewalkFree = (x, y) => x >= 0 && y >= 0 && x < cols && y < rows && !roadCells.has(key(x, y)) && !buildingCells.has(key(x, y)); + const propIcons = ["tree", "lamp", "bench", "hydrant", "tree", "planter"]; + + for (const block of blocks) { + // lamp posts at corners + for (const [cx, cy] of [[block.x, block.y], [block.x + block.w - 1, block.y], [block.x, block.y + block.h - 1], [block.x + block.w - 1, block.y + block.h - 1]]) { + if (sidewalkFree(cx, cy)) props.push({ x: cx + 0.5, y: cy + 0.5, kind: "lamp" }); + } + // sidewalk props + curb cars along each edge + edgeWalk(block, (sx, sy, ox, oy, horizontal, i) => { + if (i % 3 === 1 && sidewalkFree(sx, sy)) props.push({ x: sx + 0.5, y: sy + 0.5, kind: propIcons[(sx + sy) % propIcons.length] }); + // parked car in the road lane just outside the curb + const rx = sx + ox, ry = sy + oy; + if (i % 4 === 2 && roadCells.has(key(rx, ry)) && !isIntersection(rx, ry, roadCells, cols, rows)) { + cars.push({ x: rx + 0.5, y: ry + 0.5, horizontal, color: carColor(rng) }); + } + }); + } + // manholes / road patches + for (const c of roadCells) { + if (rng.chance(0.012)) { const [x, y] = c.split(",").map(Number); props.push({ x: x + 0.5, y: y + 0.5, kind: rng.chance(0.5) ? "manhole" : "patch" }); } + } + + return { + cols, rows, style: "city", roadCells, + roadLines: roadCenterLines(colStrips, rowStrips), + blocks, buildings, parks, crosswalks, cars, props, + plots: [], trees: [], water: null, features: [], segments + }; +} + +function edgeWalk(block, fn) { + let i = 0; + for (let x = block.x; x < block.x + block.w; x += 1) { fn(x, block.y, 0, -1, true, i++); } + for (let y = block.y; y < block.y + block.h; y += 1) { fn(block.x + block.w - 1, y, 1, 0, false, i++); } + for (let x = block.x + block.w - 1; x >= block.x; x -= 1) { fn(x, block.y + block.h - 1, 0, 1, true, i++); } + for (let y = block.y + block.h - 1; y >= block.y; y -= 1) { fn(block.x, y, -1, 0, false, i++); } +} + +function isIntersection(x, y, roadCells, cols, rows) { + // a road cell with road neighbours on perpendicular axes = intersection + const r = (xx, yy) => roadCells.has(key(xx, yy)); + return r(x - 1, y) && r(x + 1, y) && r(x, y - 1) && r(x, y + 1); +} + +function buildCrosswalks(colStrips, rowStrips) { + const out = []; + const cBlocks = colStrips.filter((s) => s.type === "block"); + const rBlocks = rowStrips.filter((s) => s.type === "block"); + const cRoads = colStrips.filter((s) => s.type === "road"); + const rRoads = rowStrips.filter((s) => s.type === "road"); + // vertical crosswalks across horizontal roads at each block's x-span + for (const rr of rRoads) for (const cb of cBlocks) out.push({ x: cb.a, y: rr.a, w: cb.b - cb.a, h: rr.b - rr.a, vertical: true }); + for (const cr of cRoads) for (const rb of rBlocks) out.push({ x: cr.a, y: rb.a, w: cr.b - cr.a, h: rb.b - rb.a, vertical: false }); + return out; +} + +function carColor(rng) { + return rng.pick(["#c9443a", "#3a6ec9", "#e0e3e8", "#2e3236", "#d8b53a", "#3aa05a", "#a0463a", "#5566cc", "#cc7a3a"]); +} + +function roadCenterLines(colStrips, rowStrips) { + const lines = []; + for (const s of colStrips) if (s.type === "road" && s.b - s.a >= 2) lines.push({ vertical: true, at: (s.a + s.b) / 2 }); + for (const s of rowStrips) if (s.type === "road" && s.b - s.a >= 2) lines.push({ vertical: false, at: (s.a + s.b) / 2 }); + return lines; +} + +// --- Village --------------------------------------------------------------- +function generateVillage(opts) { + const cols = opts.cols, rows = opts.rows; + const rng = new Rng(opts.seed ?? "village"); + + const horizontal = rng.chance(0.5); + const pts = []; + for (let i = 0; i <= 5; i += 1) { + const t = i / 5; + if (horizontal) pts.push({ x: t * cols, y: rows * (0.5 + (rng.float() - 0.5) * 0.5) }); + else pts.push({ x: cols * (0.5 + (rng.float() - 0.5) * 0.5), y: t * rows }); + } + const road = chaikin(pts, 3); + const roadHalf = opts.roadHalf ?? 1.3; + + const roadCells = new Set(); + const distToRoad = (cx, cy) => { let m = Infinity; for (let i = 0; i < road.length - 1; i += 1) m = Math.min(m, segDist(cx, cy, road[i], road[i + 1])); return m; }; + for (let y = 0; y < rows; y += 1) for (let x = 0; x < cols; x += 1) if (distToRoad(x + 0.5, y + 0.5) <= roadHalf) roadCells.add(key(x, y)); + + let water = null; + if (rng.chance(0.55)) { + const cx = cols * (0.12 + rng.float() * 0.2), cy = rows * (0.62 + rng.float() * 0.3), r = Math.min(cols, rows) * 0.12; + water = { loop: lumpy(cx, cy, r, rng), cx, cy, r }; + } + const inWater = (cx, cy) => water && Math.hypot(cx - water.cx, cy - water.cy) < water.r * 1.1; + + // Village square near road centre. + const mid = road[Math.floor(road.length / 2)]; + const square = { x: Math.round(mid.x - 2.5), y: Math.round(mid.y - 2.5), w: 5, h: 5 }; + + const buildings = []; + const buildingCells = new Set(); + const blocked = (x, y, w, h) => { + for (let yy = y - 1; yy < y + h + 1; yy += 1) for (let xx = x - 1; xx < x + w + 1; xx += 1) { + if (roadCells.has(key(xx, yy)) || inWater(xx + 0.5, yy + 0.5)) return true; + if (xx >= square.x - 1 && xx < square.x + square.w + 1 && yy >= square.y - 1 && yy < square.y + square.h + 1) return true; + } + return buildings.some((b) => x < b.x + b.w + 1 && x + w + 1 > b.x && y < b.y + b.h + 1 && y + h + 1 > b.y); + }; + const want = opts.houses ?? Math.round((cols * rows) / 55); + let guard = 0; + while (buildings.length < want && guard++ < want * 80) { + const w = rng.int(2, 4), h = rng.int(2, 4); + const x = rng.int(1, cols - w - 1), y = rng.int(1, rows - h - 1); + if (distToRoad(x + w / 2, y + h / 2) > 6) continue; + if (blocked(x, y, w, h)) continue; + const b = { x, y, w, h, roof: rng.pick(VILLAGE_ROOFS), kind: "house", chimney: rng.chance(0.6) }; + buildings.push(b); + for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) buildingCells.add(key(xx, yy)); + } + + const plots = []; + for (const b of buildings) { + if (!rng.chance(0.4)) continue; + const pw = rng.int(2, 3), ph = rng.int(2, 3), px = b.x, py = b.y + b.h + 1; + if (py + ph >= rows - 1) continue; + if (buildings.some((o) => px < o.x + o.w && px + pw > o.x && py < o.y + o.h && py + ph > o.y)) continue; + if (roadCells.has(key(px, py))) continue; + plots.push({ x: px, y: py, w: pw, h: ph, crop: rng.int(0, 2) }); + } + + // Props: well/fountain on square, market stalls + carts near road, bushes/flowers on grass. + const props = []; + props.push({ x: square.x + square.w / 2, y: square.y + square.h / 2, kind: rng.chance(0.5) ? "well" : "fountain" }); + const grassFree = (x, y) => x >= 0 && y >= 0 && x < cols && y < rows && !roadCells.has(key(x, y)) && !buildingCells.has(key(x, y)) && !inWater(x + 0.5, y + 0.5); + // stalls/carts ringing the square + let ring = 0; + for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, 1]]) { + const sx = square.x + (dx < 0 ? -1 : dx > 0 ? square.w : Math.floor(square.w / 2)); + const sy = square.y + (dy < 0 ? -1 : dy > 0 ? square.h : Math.floor(square.h / 2)); + if (grassFree(sx, sy)) { props.push({ x: sx + 0.5, y: sy + 0.5, kind: ring % 2 ? "stall" : "cart", color: stallColor(rng) }); ring += 1; } + } + // scattered bushes / flower beds / barrels / haybales + const scatter = ["bush", "flowers", "bush", "barrel", "haybale", "bush", "lamp"]; + let sg = 0, placed = 0; + const wantScatter = Math.round((cols * rows) / 22); + while (placed < wantScatter && sg++ < wantScatter * 30) { + const x = rng.int(0, cols - 1), y = rng.int(0, rows - 1); + if (!grassFree(x, y)) continue; + if (props.some((p) => Math.abs(p.x - (x + 0.5)) < 1.4 && Math.abs(p.y - (y + 0.5)) < 1.4)) continue; + props.push({ x: x + 0.5, y: y + 0.5, kind: scatter[(x * 3 + y) % scatter.length], color: stallColor(rng) }); + placed += 1; + } + + const trees = []; + const treeCount = Math.round((cols * rows) / 32); + let tg = 0; + while (trees.length < treeCount && tg++ < treeCount * 30) { + const x = rng.range(0.5, cols - 0.5), y = rng.range(0.5, rows - 0.5); + if (!grassFree(Math.floor(x), Math.floor(y))) continue; + trees.push({ x, y, r: 0.5 + rng.float() * 0.45, green: rng.int(0, 2) }); + } + + const segments = []; + for (const b of buildings) segments.push(...rectPerimeterWalls(b)); + const features = []; + if (water) features.push({ x: water.cx, y: water.cy, label: "Pond" }); + features.push({ x: square.x + square.w / 2, y: square.y - 0.3, label: "Square" }); + + return { cols, rows, style: "village", roadCells, road, roadHalf, square, blocks: [], buildings, parks: [], crosswalks: [], cars: [], props, plots, trees, water, features, segments }; +} + +function stallColor(rng) { return rng.pick(["#c0432f", "#3a78c0", "#d8a93a", "#4a9a55", "#9a4aa0", "#c87a2f"]); } + +function lumpy(cx, cy, baseR, rng) { + const n = 14, j = 0.5, pts = []; + for (let i = 0; i < n; i += 1) { const a = (i / n) * Math.PI * 2, r = baseR * (1 - j / 2 + rng.float() * j); pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }); } + return chaikin(pts, 2); +} + +function segDist(px, py, a, b) { + const dx = b.x - a.x, dy = b.y - a.y, len2 = dx * dx + dy * dy || 1; + let t = ((px - a.x) * dx + (py - a.y) * dy) / len2; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(px - (a.x + t * dx), py - (a.y + t * dy)); +} diff --git a/module/lib/walls.mjs b/module/lib/walls.mjs new file mode 100644 index 0000000..c260344 --- /dev/null +++ b/module/lib/walls.mjs @@ -0,0 +1,274 @@ +/** + * Region grid -> wall / door / window segments. + * + * This is the single source of truth that feeds BOTH the SVG renderer and the + * Foundry wall/door creation, so the picture and the playable walls are always + * identical. Pure / Node-testable. All coordinates in GRID CELLS. + * + * A "unit edge" lives on a cell boundary: + * vertical edge at line X, row y -> from (X, y) to (X, y+1) + * horizontal edge at line Y, col x -> from (x, Y) to (x+1, Y) + * + * Output segment shape: + * { x1, y1, x2, y2, orient: "v"|"h", kind, sides: [a, b], inside } + * kind: "wall" | "door" | "window" + * locked / secret flags carried on door segments + */ + +const OUTSIDE = -1; + +function reg(region, x, y, cols, rows) { + if (x < 0 || y < 0 || x >= cols || y >= rows) return OUTSIDE; + return region[y][x]; +} + +function pairKey(a, b) { + return a < b ? `${a}:${b}` : `${b}:${a}`; +} + +// --- Union-find for the door spanning tree ------------------------------- +function makeDSU(n) { + const parent = Array.from({ length: n }, (_, i) => i); + const find = (i) => { + while (parent[i] !== i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + }; + const union = (a, b) => { + const ra = find(a); + const rb = find(b); + if (ra === rb) return false; + parent[ra] = rb; + return true; + }; + return { find, union }; +} + +/** + * @param {object} layout output of generateBuilding() + * @param {object} opts + * @param {import("./rng.mjs").Rng} opts.rng + * @param {number} [opts.loopProb] chance of an extra (non-tree) door, 0..1 + * @param {number} [opts.windowEvery] spacing of windows along exterior runs + * @param {boolean} [opts.windows] place windows at all + * @param {number} [opts.extraDoors] extra exterior doors for large buildings + * @returns {{ segments, doors, windows, walls }} + */ +export function buildWalls(layout, opts) { + const { region, rooms, cols, rows, footprint } = layout; + const rng = opts.rng; + const loopProb = opts.loopProb ?? 0.3; + const windowEvery = opts.windowEvery ?? 4; + const wantWindows = opts.windows !== false; + + // edges keyed by "orient:x:y" -> { orient, x, y, a, b, kind, exterior } + const edges = new Map(); + const key = (o, x, y) => `${o}:${x}:${y}`; + + // Vertical edges (line X between cell x-1 and x). + for (let x = 0; x <= cols; x += 1) { + for (let y = 0; y < rows; y += 1) { + const a = reg(region, x - 1, y, cols, rows); + const b = reg(region, x, y, cols, rows); + if (a === b) continue; + edges.set(key("v", x, y), { + orient: "v", x, y, a, b, kind: "wall", + exterior: a === OUTSIDE || b === OUTSIDE + }); + } + } + // Horizontal edges (line Y between cell y-1 and y). + for (let y = 0; y <= rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + const a = reg(region, x, y - 1, cols, rows); + const b = reg(region, x, y, cols, rows); + if (a === b) continue; + edges.set(key("h", x, y), { + orient: "h", x, y, a, b, kind: "wall", + exterior: a === OUTSIDE || b === OUTSIDE + }); + } + } + + // --- Interior doors: connect all rooms (spanning tree) + a few loops ------ + const interior = [...edges.values()].filter((e) => !e.exterior); + const pairs = new Map(); // pairKey -> edge list + for (const e of interior) { + const k = pairKey(e.a, e.b); + if (!pairs.has(k)) pairs.set(k, []); + pairs.get(k).push(e); + } + + const dsu = makeDSU(rooms.length); + const pairEntries = rng.shuffle([...pairs.entries()]); + for (const [k, list] of pairEntries) { + const [a, b] = k.split(":").map(Number); + const joined = dsu.union(a, b); + if (joined || rng.float() < loopProb) { + markDoor(centreEdge(list), rooms); + } + } + + // --- Exterior runs: doors + windows -------------------------------------- + const exterior = [...edges.values()].filter((e) => e.exterior); + const runs = groupRuns(exterior); + + // Main entrance: middle of the entrance room's south exterior edges. + const entrance = rooms.find((r) => r.type === "entrance") ?? rooms[0]; + placeEntranceDoor(entrance, footprint, edges, key); + + // A side door for larger buildings. + const extraDoors = opts.extraDoors ?? (rooms.length >= 10 ? 1 : 0); + for (let i = 0; i < extraDoors; i += 1) { + const run = rng.pick(runs.filter((rn) => rn.length >= 3)); + if (run) markDoorEdge(run[Math.floor(run.length / 2)]); + } + + if (wantWindows) { + for (const run of runs) { + if (run.length < 3) continue; + for (let i = 1; i < run.length - 1; i += windowEvery) { + const e = run[i]; + if (e.kind === "wall") e.kind = "window"; + } + } + } + + // --- Merge colinear walls; emit doors/windows as unit segments ----------- + const segments = mergeSegments(edges); + return { + segments, + walls: segments.filter((s) => s.kind === "wall"), + doors: segments.filter((s) => s.kind === "door"), + windows: segments.filter((s) => s.kind === "window") + }; +} + +/** Pick the middle edge of the longest contiguous run within an edge list. */ +function centreEdge(list) { + const runs = groupRuns(list); + runs.sort((a, b) => b.length - a.length); + const run = runs[0]; + return run[Math.floor(run.length / 2)]; +} + +function markDoor(edge, rooms) { + edge.kind = "door"; + const ra = rooms[edge.a]; + const rb = rooms[edge.b]; + edge.locked = !!(ra?.locked || rb?.locked); + // Hinge swings into the higher-id / non-locked room by convention. + edge.inside = edge.orient === "v" ? "b" : "b"; +} + +function markDoorEdge(edge) { + edge.kind = "door"; + edge.locked = false; + edge.inside = "b"; +} + +/** Group a flat edge list into contiguous colinear runs. */ +function groupRuns(list) { + const byLine = new Map(); + for (const e of list) { + // For vertical edges runs go along y on a fixed x; horizontal along x on fixed y. + const lineKey = e.orient === "v" ? `v:${e.x}` : `h:${e.y}`; + if (!byLine.has(lineKey)) byLine.set(lineKey, []); + byLine.get(lineKey).push(e); + } + const runs = []; + for (const [, group] of byLine) { + const axis = group[0].orient === "v" ? "y" : "x"; + group.sort((a, b) => a[axis] - b[axis]); + let run = [group[0]]; + for (let i = 1; i < group.length; i += 1) { + if (group[i][axis] === group[i - 1][axis] + 1) { + run.push(group[i]); + } else { + runs.push(run); + run = [group[i]]; + } + } + runs.push(run); + } + return runs; +} + +function placeEntranceDoor(entrance, footprint, edges, key) { + if (!entrance) return; + const southY = entrance.y + entrance.h; + const onSouthEdge = southY === footprint.y + footprint.h; + let target = null; + if (onSouthEdge) { + const midX = Math.floor(entrance.x + entrance.w / 2); + target = edges.get(key("h", midX, southY)); + } + if (!target) { + // Fall back: any exterior edge of the entrance room. + for (const e of edges.values()) { + if (!e.exterior) continue; + if (e.a === entrance.id || e.b === entrance.id) { target = e; break; } + } + } + if (target) { + target.kind = "door"; + target.locked = false; + target.inside = "a"; // swing inward + } +} + +/** Merge unit wall edges into long segments; doors/windows stay unit-length. */ +function mergeSegments(edges) { + const segments = []; + const walls = []; + for (const e of edges.values()) { + if (e.kind === "wall") walls.push(e); + else segments.push(unitSegment(e)); + } + // group walls by line and merge consecutive + const byLine = new Map(); + for (const e of walls) { + const lineKey = e.orient === "v" ? `v:${e.x}` : `h:${e.y}`; + if (!byLine.has(lineKey)) byLine.set(lineKey, []); + byLine.get(lineKey).push(e); + } + for (const [, group] of byLine) { + const o = group[0].orient; + const axis = o === "v" ? "y" : "x"; + group.sort((a, b) => a[axis] - b[axis]); + let start = group[0]; + let prev = group[0]; + const flush = (endEdge) => { + if (o === "v") { + segments.push({ x1: start.x, y1: start.y, x2: start.x, y2: endEdge.y + 1, orient: "v", kind: "wall" }); + } else { + segments.push({ x1: start.x, y1: start.y, x2: endEdge.x + 1, y2: start.y, orient: "h", kind: "wall" }); + } + }; + for (let i = 1; i < group.length; i += 1) { + if (group[i][axis] === prev[axis] + 1) { + prev = group[i]; + } else { + flush(prev); + start = group[i]; + prev = group[i]; + } + } + flush(prev); + } + return segments; +} + +function unitSegment(e) { + const seg = e.orient === "v" + ? { x1: e.x, y1: e.y, x2: e.x, y2: e.y + 1 } + : { x1: e.x, y1: e.y, x2: e.x + 1, y2: e.y }; + seg.orient = e.orient; + seg.kind = e.kind; + seg.sides = [e.a, e.b]; + if (e.locked) seg.locked = true; + if (e.inside) seg.inside = e.inside; + return seg; +} diff --git a/module/mapwright.mjs b/module/mapwright.mjs new file mode 100644 index 0000000..ec906a6 --- /dev/null +++ b/module/mapwright.mjs @@ -0,0 +1,61 @@ +/** + * Mapwright — entry point. + * + * Registers the toolbar button + a global API hook. The heavy lifting lives in + * lib/* (pure, Node-testable) and app/* (the dialog). + */ + +import { MapwrightApp } from "./app/generator-app.mjs"; + +const MODULE_ID = "mapwright"; + +Hooks.once("init", () => { + game.mapwright = { open: () => MapwrightApp.open() }; +}); + +// Toolbar button on the Walls control group (v13 array-or-object shape). +Hooks.on("getSceneControlButtons", (controls) => { + if (!game.user?.isGM) return; + + const tool = { + name: "mapwrightOpen", + title: game.i18n.localize("MAPWRIGHT.ToolbarOpen"), + icon: "fas fa-map", + button: true, + onClick: () => MapwrightApp.open() + }; + + if (Array.isArray(controls)) { + const walls = controls.find((c) => c.name === "walls"); + if (walls) { + walls.tools ??= []; + if (!walls.tools.some((t) => t.name === tool.name)) walls.tools.push(tool); + } else { + controls.push({ name: MODULE_ID, title: "Mapwright", icon: "fas fa-map", layer: "walls", tools: [tool] }); + } + return; + } + + if (controls && typeof controls === "object") { + const walls = controls.walls; + if (walls) { + walls.tools ??= {}; + walls.tools[tool.name] ??= tool; + } + } +}); + +// Scene directory header button as a second entry point. +Hooks.on("renderSceneDirectory", (_app, html) => { + if (!game.user?.isGM) return; + const root = html instanceof HTMLElement ? html : html?.[0]; + if (!root) return; + const header = root.querySelector(".directory-header .action-buttons") ?? root.querySelector(".header-actions"); + if (!header || header.querySelector(".mapwright-open")) return; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "mapwright-open"; + btn.innerHTML = ` ${game.i18n.localize("MAPWRIGHT.SceneButton")}`; + btn.addEventListener("click", () => MapwrightApp.open()); + header.appendChild(btn); +}); diff --git a/styles/mapwright.css b/styles/mapwright.css new file mode 100644 index 0000000..432de5a --- /dev/null +++ b/styles/mapwright.css @@ -0,0 +1,109 @@ +.mapwright .mapwright-layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 12px; + height: 100%; + min-height: 0; +} + +.mapwright .mapwright-controls { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding-right: 4px; +} + +.mapwright .mapwright-field { + display: flex; + flex-direction: column; + gap: 3px; +} + +.mapwright .mapwright-field > label { + font-weight: 600; + font-size: 12px; + opacity: 0.8; +} + +.mapwright .mapwright-field input[type="text"], +.mapwright .mapwright-field select { + width: 100%; +} + +.mapwright .mapwright-seed-row { + display: flex; + gap: 6px; +} + +.mapwright .mapwright-seed-row input { flex: 1; } + +.mapwright .mapwright-icon-btn { + flex: 0 0 auto; + width: 34px; +} + +.mapwright .mapwright-toggles { + display: flex; + flex-direction: column; + gap: 4px; + border: 1px solid var(--color-border-light-tertiary, #7a7971); + border-radius: 4px; + padding: 6px 8px; +} + +.mapwright .mapwright-toggles legend { font-weight: 600; font-size: 12px; } + +.mapwright .mapwright-toggles label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.mapwright .mapwright-toggles input[type="checkbox"] { flex: 0 0 auto; } + +.mapwright .mapwright-stats { + font-size: 12px; + opacity: 0.75; + min-height: 16px; +} + +.mapwright .mapwright-create { + margin-top: auto; + padding: 8px; + font-size: 15px; + font-weight: 600; +} + +.mapwright .mapwright-hint { + font-size: 11px; + opacity: 0.6; + margin: 0; + line-height: 1.3; +} + +.mapwright .mapwright-stage { + min-width: 0; + min-height: 0; + display: flex; + background: #14161a; + border: 1px solid #000; + border-radius: 4px; + overflow: auto; + padding: 8px; +} + +.mapwright .mapwright-preview { + margin: auto; + width: 100%; +} + +.mapwright .mapwright-preview svg { + display: block; + width: 100%; + height: auto; + border-radius: 2px; +} + +.mapwright .mapwright-error { color: #ff6b6b; padding: 12px; } diff --git a/templates/generator.html b/templates/generator.html new file mode 100644 index 0000000..61bb5f1 --- /dev/null +++ b/templates/generator.html @@ -0,0 +1,75 @@ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ Include + + + + + + +
+ +
+ + +

The preview is exactly what gets built — walls, doors and windows are placed automatically.

+
+ +
+
+
+