Mapwright v0.5.0 — procedural battle map generator for Foundry VTT
Buildings (modern/fantasy, multi-floor, footprint shapes), caves, outdoor biomes, and town/village + city-block settlements. Auto-places Foundry walls, doors, windows, lighting; furniture as locked tiles; live preview. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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) => `<option value="${v.value}">${v.label}</option>`).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 = `<p class="mapwright-error">Preview failed: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(wpx)}" height="${Math.round(hpx)}" viewBox="0 0 ${wpx} ${hpx}">${inner}</svg>`;
|
||||
}
|
||||
|
||||
/** Inner SVG for a piece (no <svg> 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 `<g stroke-linejoin="round">` +
|
||||
`<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="${r2(w * 0.06)}" fill="${pal.fabric}" stroke="${pal.line}" stroke-width="${r2(w * 0.03)}"/>` +
|
||||
`<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${pillow}" rx="${r2(w * 0.05)}" fill="${pal.soft}" stroke="${pal.line}" stroke-width="${r2(w * 0.02)}"/></g>`;
|
||||
}
|
||||
function drawChest(w, h, pal) {
|
||||
const b = box(w, h, 0.18);
|
||||
return `<g><rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="${r2(w * 0.04)}" fill="${pal.wood}" stroke="${pal.woodLine}" stroke-width="${r2(w * 0.04)}"/>` +
|
||||
`<line x1="${b.x}" y1="${r2(b.y + b.h * 0.4)}" x2="${r2(b.x + b.w)}" y2="${r2(b.y + b.h * 0.4)}" stroke="${pal.woodLine}" stroke-width="${r2(w * 0.03)}"/></g>`;
|
||||
}
|
||||
function drawDesk(w, h, pal) {
|
||||
const b = box(w, h, 0.1);
|
||||
return `<g><rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="${r2(h * 0.06)}" fill="${pal.wood}" stroke="${pal.woodLine}" stroke-width="${r2(h * 0.05)}"/>` +
|
||||
`<rect x="${r2(b.x + b.w * 0.55)}" y="${r2(b.y + b.h * 0.18)}" width="${r2(b.w * 0.3)}" height="${r2(b.h * 0.5)}" fill="${pal.metal}" stroke="${pal.metalLine}" stroke-width="${r2(h * 0.03)}"/></g>`;
|
||||
}
|
||||
function drawChair(w, h, pal) {
|
||||
const b = box(w, h, 0.26);
|
||||
return `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="${r2(w * 0.12)}" fill="${pal.seat}" stroke="${pal.line}" stroke-width="${r2(w * 0.04)}"/>`;
|
||||
}
|
||||
function drawTable(w, h, pal) {
|
||||
const b = box(w, h, 0.16);
|
||||
return `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="${r2(w * 0.08)}" fill="${pal.wood}" stroke="${pal.woodLine}" stroke-width="${r2(w * 0.05)}"/>`;
|
||||
}
|
||||
function drawBigTable(w, h, pal) {
|
||||
const b = box(w, h, 0.2);
|
||||
let s = `<g>`;
|
||||
// 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 += `<circle cx="${cx}" cy="${r2(b.y - cr * 1.2)}" r="${cr}" fill="${pal.seat}" stroke="${pal.line}" stroke-width="${r2(w * 0.012)}"/>`;
|
||||
s += `<circle cx="${cx}" cy="${r2(b.y + b.h + cr * 1.2)}" r="${cr}" fill="${pal.seat}" stroke="${pal.line}" stroke-width="${r2(w * 0.012)}"/>`;
|
||||
}
|
||||
s += `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="${r2(Math.min(w, h) * 0.08)}" fill="${pal.wood}" stroke="${pal.woodLine}" stroke-width="${r2(Math.min(w, h) * 0.04)}"/>`;
|
||||
return s + `</g>`;
|
||||
}
|
||||
function drawCounter(w, h, pal) {
|
||||
const b = box(w, h, 0.12);
|
||||
const top = r2(b.h * 0.5);
|
||||
return `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${top}" fill="${pal.metal}" stroke="${pal.metalLine}" stroke-width="${r2(h * 0.05)}"/>`;
|
||||
}
|
||||
function drawRacks(w, h, pal) {
|
||||
let s = `<g>`;
|
||||
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 += `<rect x="${x}" y="${b.y}" width="${r2(b.w / 3 * 0.7)}" height="${b.h}" fill="${pal.metal}" stroke="${pal.metalLine}" stroke-width="${r2(w * 0.025)}"/>`;
|
||||
}
|
||||
return s + `</g>`;
|
||||
}
|
||||
function drawToilet(w, h, pal) {
|
||||
const cx = r2(w * 0.5);
|
||||
return `<g><ellipse cx="${cx}" cy="${r2(h * 0.6)}" rx="${r2(w * 0.26)}" ry="${r2(h * 0.3)}" fill="${pal.soft}" stroke="${pal.line}" stroke-width="${r2(w * 0.035)}"/>` +
|
||||
`<rect x="${r2(cx - w * 0.22)}" y="${r2(h * 0.12)}" width="${r2(w * 0.44)}" height="${r2(h * 0.2)}" rx="${r2(w * 0.04)}" fill="${pal.soft}" stroke="${pal.line}" stroke-width="${r2(w * 0.03)}"/></g>`;
|
||||
}
|
||||
function drawSink(w, h, pal) {
|
||||
const b = box(w, h, 0.24);
|
||||
return `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="${r2(w * 0.1)}" fill="${pal.soft}" stroke="${pal.line}" stroke-width="${r2(w * 0.035)}"/>`;
|
||||
}
|
||||
function drawCrates(w, h, pal) {
|
||||
let s = `<g>`;
|
||||
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 += `<rect x="${r2(w * ox)}" y="${r2(h * oy)}" width="${sz}" height="${sz}" fill="${pal.wood}" stroke="${pal.woodLine}" stroke-width="${r2(w * 0.03)}"/>`;
|
||||
}
|
||||
return s + `</g>`;
|
||||
}
|
||||
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 `<g><rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="${pal.wood}" stroke="${pal.woodLine}" stroke-width="${r2(w * 0.035)}"/>` +
|
||||
`<line x1="${x}" y1="${r2(y + bh / 2)}" x2="${r2(x + bw)}" y2="${r2(y + bh / 2)}" stroke="${pal.woodLine}" stroke-width="${r2(w * 0.02)}"/></g>`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } }
|
||||
}));
|
||||
}
|
||||
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`);
|
||||
|
||||
out.push(`<defs>`);
|
||||
out.push(
|
||||
`<filter id="mw-rockshadow" x="-20%" y="-20%" width="140%" height="140%">` +
|
||||
`<feDropShadow dx="0" dy="0" stdDeviation="${(gs * 0.09).toFixed(1)}" flood-color="#000" flood-opacity="0.55"/></filter>`
|
||||
);
|
||||
out.push(`<clipPath id="mw-cavefloor" clip-rule="evenodd"><path d="${floorPath}" clip-rule="evenodd"/></clipPath>`);
|
||||
out.push(`</defs>`);
|
||||
|
||||
// Rock surround.
|
||||
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${theme.outside}"/>`);
|
||||
out.push(rockTexture(W, H, theme, gs, rng));
|
||||
|
||||
// Floor fill (evenodd handles pillars/holes).
|
||||
out.push(`<path d="${floorPath}" fill="${theme.floorBase}" fill-rule="evenodd"/>`);
|
||||
|
||||
// Floor detail clipped to floor.
|
||||
out.push(`<g clip-path="url(#mw-cavefloor)">`);
|
||||
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(`<path d="${floorPath}" fill="none" fill-rule="evenodd" stroke="${theme.floorEdge}" stroke-width="${(gs * 0.34).toFixed(1)}" opacity="0.5"/>`);
|
||||
out.push(`</g>`);
|
||||
|
||||
// Walls: cast shadow, body, inner highlight.
|
||||
const wallW = (gs * 0.26).toFixed(1);
|
||||
out.push(`<g filter="url(#mw-rockshadow)" fill="none" stroke-linejoin="round">`);
|
||||
out.push(`<path d="${floorPath}" fill-rule="evenodd" stroke="${theme.wall}" stroke-width="${wallW}"/>`);
|
||||
out.push(`</g>`);
|
||||
out.push(`<path d="${floorPath}" fill="none" fill-rule="evenodd" stroke="${theme.wallHi}" stroke-width="${(gs * 0.05).toFixed(1)}" opacity="0.5"/>`);
|
||||
|
||||
// Feature labels.
|
||||
if (showLabels && model.features?.length) {
|
||||
out.push(`<g font-family="'Segoe UI', sans-serif" text-anchor="middle" fill="${theme.label}">`);
|
||||
for (const f of model.features) {
|
||||
const fs = Math.max(10, gs * 0.22);
|
||||
out.push(`<circle cx="${PX(f.x, gs)}" cy="${PX(f.y, gs)}" r="${(gs * 0.08).toFixed(1)}" fill="${theme.label}" opacity="0.5"/>`);
|
||||
out.push(`<text x="${PX(f.x, gs)}" y="${(PX(f.y, gs) - gs * 0.18).toFixed(1)}" font-size="${fs.toFixed(1)}" font-weight="600" opacity="0.85">${escapeXml(f.label)}</text>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
}
|
||||
|
||||
out.push(`</svg>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function rockTexture(W, H, theme, gs, rng) {
|
||||
// Sparse rock cracks/blotches outside, very subtle.
|
||||
const out = [`<g stroke="${theme.wallHi}" stroke-width="${(gs * 0.02).toFixed(1)}" opacity="0.12" fill="none">`];
|
||||
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(`<line x1="${x.toFixed(1)}" y1="${y.toFixed(1)}" x2="${(x + Math.cos(ang) * len).toFixed(1)}" y2="${(y + Math.sin(ang) * len).toFixed(1)}"/>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function floorDebris(model, gs, theme, rng) {
|
||||
const out = [`<g>`];
|
||||
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(`<circle cx="${PX(x, gs)}" cy="${PX(y, gs)}" r="${r.toFixed(1)}" fill="${col}" opacity="${(0.25 + rng.float() * 0.3).toFixed(2)}"/>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function caveGrid(model, gs, color) {
|
||||
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.18">`];
|
||||
for (let x = 0; x <= model.cols; x += 1) out.push(`<line x1="${PX(x, gs)}" y1="0" x2="${PX(x, gs)}" y2="${PX(model.rows, gs)}"/>`);
|
||||
for (let y = 0; y <= model.rows; y += 1) out.push(`<line x1="0" y1="${PX(y, gs)}" x2="${PX(model.cols, gs)}" y2="${PX(y, gs)}"/>`);
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function escapeXml(str) {
|
||||
return String(str).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]));
|
||||
}
|
||||
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`);
|
||||
out.push(`<defs>` +
|
||||
`<filter id="mw-soft" x="-30%" y="-30%" width="160%" height="160%">` +
|
||||
`<feDropShadow dx="0" dy="${(gs * 0.04).toFixed(1)}" stdDeviation="${(gs * 0.05).toFixed(1)}" flood-color="#000" flood-opacity="0.3"/></filter>` +
|
||||
`</defs>`);
|
||||
|
||||
// Ground base + soft colour patches + tufts.
|
||||
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${t.ground}"/>`);
|
||||
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(`<path d="${pathFor(m.water.loop, gs)}" fill="${t.water}" stroke="${t.waterEdge}" stroke-width="${(gs * 0.06).toFixed(1)}"/>`);
|
||||
out.push(ripples(m.water, gs, t, rng));
|
||||
}
|
||||
|
||||
// Rocks.
|
||||
for (const rk of m.rocks) {
|
||||
out.push(`<g filter="url(#mw-soft)">`);
|
||||
out.push(`<path d="${pathFor(rk.loop, gs)}" fill="${t.rock}" stroke="${t.rockShadow}" stroke-width="${(gs * 0.04).toFixed(1)}"/>`);
|
||||
out.push(`</g>`);
|
||||
// a couple of highlight facets
|
||||
out.push(`<path d="${pathFor(rk.loop, gs)}" fill="none" stroke="${t.rockHi}" stroke-width="${(gs * 0.02).toFixed(1)}" opacity="0.5"/>`);
|
||||
}
|
||||
|
||||
// Trees (sorted by y for overlap depth).
|
||||
const trees = m.trees.slice().sort((a, b) => a.y - b.y);
|
||||
out.push(`<g filter="url(#mw-soft)">`);
|
||||
for (const tr of trees) {
|
||||
const cx = PX(tr.x, gs), cy = PX(tr.y, gs), r = tr.r * gs;
|
||||
out.push(`<circle cx="${cx}" cy="${cy}" r="${r.toFixed(1)}" fill="${t.tree}"/>`);
|
||||
out.push(`<circle cx="${(cx - r * 0.25).toFixed(1)}" cy="${(cy - r * 0.25).toFixed(1)}" r="${(r * 0.62).toFixed(1)}" fill="${t.treeHi}" opacity="0.8"/>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
|
||||
// Feature labels.
|
||||
if (showLabels && m.features?.length) {
|
||||
out.push(`<g font-family="'Segoe UI', sans-serif" text-anchor="middle" fill="${t.label}">`);
|
||||
for (const f of m.features) {
|
||||
out.push(`<text x="${PX(f.x, gs)}" y="${PX(f.y, gs)}" font-size="${Math.max(10, gs * 0.22).toFixed(1)}" font-weight="700" opacity="0.85" stroke="#000" stroke-width="0.6" paint-order="stroke">${escapeXml(f.label)}</text>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
}
|
||||
|
||||
out.push(`</svg>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function groundPatches(m, gs, t, rng) {
|
||||
const out = [`<g>`];
|
||||
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(`<ellipse cx="${PX(x, gs)}" cy="${PX(y, gs)}" rx="${rx.toFixed(1)}" ry="${ry.toFixed(1)}" fill="${col}" opacity="0.35"/>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function tufts(m, gs, t, rng) {
|
||||
const out = [`<g stroke="${t.tuft}" stroke-width="${(gs * 0.02).toFixed(1)}" stroke-linecap="round" opacity="0.5">`];
|
||||
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(`<path d="M${x},${y} l${(-h * 0.3).toFixed(1)},${(-h).toFixed(1)} M${x},${y} l${(h * 0.3).toFixed(1)},${(-h * 0.9).toFixed(1)}"/>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function ripples(water, gs, t, rng) {
|
||||
const out = [`<g stroke="${t.waterHiSafe}" stroke-width="${(gs * 0.02).toFixed(1)}" fill="none" opacity="0.35">`];
|
||||
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(`<path d="M${PX(x, gs) - w},${PX(y, gs)} q${w.toFixed(1)},${(-w * 0.5).toFixed(1)} ${(w * 2).toFixed(1)},0"/>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function grid(m, gs, color) {
|
||||
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.01).toFixed(1)}" opacity="0.16">`];
|
||||
for (let x = 0; x <= m.cols; x += 1) out.push(`<line x1="${PX(x, gs)}" y1="0" x2="${PX(x, gs)}" y2="${PX(m.rows, gs)}"/>`);
|
||||
for (let y = 0; y <= m.rows; y += 1) out.push(`<line x1="0" y1="${PX(y, gs)}" x2="${PX(m.cols, gs)}" y2="${PX(y, gs)}"/>`);
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function escapeXml(str) {
|
||||
return String(str).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]));
|
||||
}
|
||||
@@ -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 = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`];
|
||||
|
||||
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(`<g font-family="'Segoe UI', sans-serif" text-anchor="middle" fill="${m.style === "city" ? CITY.label : VILLAGE.label}">`);
|
||||
for (const f of m.features) out.push(`<text x="${PX(f.x, gs)}" y="${PX(f.y, gs)}" font-size="${Math.max(11, gs * 0.2).toFixed(1)}" font-weight="700" stroke="#000" stroke-width="0.7" paint-order="stroke" opacity="0.85">${esc(f.label)}</text>`);
|
||||
out.push(`</g>`);
|
||||
}
|
||||
out.push(`</svg>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
// ============================ CITY ========================================
|
||||
function renderCity(out, m, gs, W, H, rng) {
|
||||
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${CITY.asphalt}"/>`);
|
||||
out.push(speckle(W, H, gs, "#000", 0.07, 4, rng));
|
||||
|
||||
// crosswalks (under sidewalks/markings)
|
||||
out.push(`<g fill="${CITY.laneWhite}" opacity="0.8">`);
|
||||
for (const cw of m.crosswalks) out.push(crosswalk(cw, gs));
|
||||
out.push(`</g>`);
|
||||
|
||||
// lane markings: solid white edge + dashed yellow centre
|
||||
out.push(`<g stroke="${CITY.laneWhite}" stroke-width="${(gs * 0.025).toFixed(1)}" opacity="0.5">`);
|
||||
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(`</g>`);
|
||||
out.push(`<g stroke="${CITY.laneYellow}" stroke-width="${(gs * 0.045).toFixed(1)}" stroke-dasharray="${(gs * 0.5).toFixed(1)} ${(gs * 0.4).toFixed(1)}" opacity="0.9">`);
|
||||
for (const ln of m.roadLines) out.push(laneLine(ln, gs, W, H, 0));
|
||||
out.push(`</g>`);
|
||||
|
||||
// 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(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${CITY.sidewalk}" stroke="${CITY.curb}" stroke-width="${(gs * 0.04).toFixed(1)}"/>`);
|
||||
}
|
||||
// sidewalk paving grid (subtle)
|
||||
out.push(`<g stroke="${CITY.curb}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.25">`);
|
||||
for (const blk of m.blocks) for (let i = 1; i < blk.w; i += 1) out.push(`<line x1="${PX(blk.x + i, gs)}" y1="${PX(blk.y, gs)}" x2="${PX(blk.x + i, gs)}" y2="${PX(blk.y + blk.h, gs)}"/>`);
|
||||
out.push(`</g>`);
|
||||
|
||||
// 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(`<g>`);
|
||||
for (const c of m.cars) out.push(car(c, gs));
|
||||
out.push(`</g>`);
|
||||
|
||||
// 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 += `<rect x="${PX(cw.x + i + 0.12, gs)}" y="${PX(cw.y + 0.15, gs)}" width="${PX(0.55, gs)}" height="${PX(cw.h - 0.3, gs)}"/>`;
|
||||
} else {
|
||||
for (let i = 0; i < cw.h; i += 1) if (i % 2 === 0) s += `<rect x="${PX(cw.x + 0.15, gs)}" y="${PX(cw.y + i + 0.12, gs)}" width="${PX(cw.w - 0.3, gs)}" height="${PX(0.55, gs)}"/>`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function laneLine(ln, gs, W, H, off) {
|
||||
if (ln.vertical) { const x = PX(ln.at + off, gs); return `<line x1="${x}" y1="0" x2="${x}" y2="${H}"/>`; }
|
||||
const y = PX(ln.at + off, gs); return `<line x1="0" y1="${y}" x2="${W}" y2="${y}"/>`;
|
||||
}
|
||||
|
||||
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(`<rect x="${(x + off).toFixed(1)}" y="${(y + off).toFixed(1)}" width="${w}" height="${h}" rx="${(gs * 0.03).toFixed(1)}" fill="#000" opacity="0.3"/>`);
|
||||
out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${(gs * 0.03).toFixed(1)}" fill="${b.roof}" stroke="${CITY.roofTrim}" stroke-width="${(gs * 0.05).toFixed(1)}"/>`);
|
||||
// inner trim
|
||||
out.push(`<rect x="${(x + gs * 0.12).toFixed(1)}" y="${(y + gs * 0.12).toFixed(1)}" width="${(w - gs * 0.24).toFixed(1)}" height="${(h - gs * 0.24).toFixed(1)}" fill="none" stroke="#fff" stroke-width="${(gs * 0.015).toFixed(1)}" opacity="0.1"/>`);
|
||||
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(`<rect x="${PX(b.x + 0.4, gs)}" y="${PX(b.y + 0.4, gs)}" width="${PX(b.w - 0.8, gs)}" height="${PX(b.h - 0.8, gs)}" fill="${CITY.park}" opacity="0.6" rx="${(gs * 0.05).toFixed(1)}"/>`);
|
||||
} else if (b.roofStyle === 2) { // water tank
|
||||
out.push(`<circle cx="${PX(cx, gs)}" cy="${PX(cy, gs)}" r="${(gs * 0.28).toFixed(1)}" fill="#5e524a" stroke="#000" stroke-width="${(gs * 0.02).toFixed(1)}" opacity="0.85"/>`);
|
||||
} 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(`<rect x="${ux}" y="${uy}" width="${us.toFixed(1)}" height="${us.toFixed(1)}" fill="${rng.chance(0.5) ? "#3a3d42" : "#8fb6d6"}" opacity="0.5"/>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 `<g>` +
|
||||
`<rect x="${(x + gs * 0.06).toFixed(1)}" y="${(y + gs * 0.06).toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${(gs * 0.12).toFixed(1)}" fill="#000" opacity="0.25"/>` +
|
||||
`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${(gs * 0.14).toFixed(1)}" fill="${c.color}" stroke="#000" stroke-width="${(gs * 0.02).toFixed(1)}"/>` +
|
||||
`<rect x="${(x + (w - wx) / 2).toFixed(1)}" y="${(y + (h - wy) / 2).toFixed(1)}" width="${wx.toFixed(1)}" height="${wy.toFixed(1)}" rx="${(gs * 0.06).toFixed(1)}" fill="#1c2024" opacity="0.55"/>` +
|
||||
`<rect x="${(x + w * 0.08).toFixed(1)}" y="${(y + h * 0.08).toFixed(1)}" width="${(w * 0.84).toFixed(1)}" height="${(h * 0.84).toFixed(1)}" rx="${(gs * 0.1).toFixed(1)}" fill="none" stroke="#fff" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.18"/></g>`;
|
||||
}
|
||||
|
||||
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 = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${(gs * 0.06).toFixed(1)}" fill="${CITY.park}"/>`;
|
||||
// diagonal path
|
||||
s += `<path d="M${x},${(y + h * 0.7).toFixed(1)} L${(x + w).toFixed(1)},${(y + h * 0.3).toFixed(1)}" stroke="${CITY.parkPath}" stroke-width="${(gs * 0.3).toFixed(1)}" fill="none" opacity="0.8"/>`;
|
||||
s += `<circle cx="${(x + w / 2).toFixed(1)}" cy="${(y + h / 2).toFixed(1)}" r="${(gs * 0.3).toFixed(1)}" fill="${VILLAGE.water}" stroke="${CITY.parkPath}" stroke-width="${(gs * 0.08).toFixed(1)}"/>`;
|
||||
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 `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.3).toFixed(1)}" fill="#ffe9a0" opacity="0.12"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.08).toFixed(1)}" fill="#2a2d31"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.045).toFixed(1)}" fill="#ffe9a0"/></g>`;
|
||||
case "bench": return `<rect x="${(cx - gs * 0.22).toFixed(1)}" y="${(cy - gs * 0.1).toFixed(1)}" width="${(gs * 0.44).toFixed(1)}" height="${(gs * 0.2).toFixed(1)}" rx="${(gs * 0.03).toFixed(1)}" fill="#7a5a36" stroke="#3a2614" stroke-width="${(gs * 0.015).toFixed(1)}"/>`;
|
||||
case "hydrant": return `<circle cx="${cx}" cy="${cy}" r="${(gs * 0.1).toFixed(1)}" fill="#c0392b" stroke="#000" stroke-width="${(gs * 0.015).toFixed(1)}"/>`;
|
||||
case "planter": return `<rect x="${(cx - gs * 0.18).toFixed(1)}" y="${(cy - gs * 0.18).toFixed(1)}" width="${(gs * 0.36).toFixed(1)}" height="${(gs * 0.36).toFixed(1)}" rx="${(gs * 0.04).toFixed(1)}" fill="#6b4a2c"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.13).toFixed(1)}" fill="#3f6e30"/>`;
|
||||
case "manhole": return `<circle cx="${cx}" cy="${cy}" r="${(gs * 0.13).toFixed(1)}" fill="#23262b" stroke="#454a50" stroke-width="${(gs * 0.02).toFixed(1)}"/>`;
|
||||
case "patch": return `<rect x="${(cx - gs * 0.3).toFixed(1)}" y="${(cy - gs * 0.2).toFixed(1)}" width="${(gs * 0.6).toFixed(1)}" height="${(gs * 0.4).toFixed(1)}" fill="#23262b" opacity="0.5"/>`;
|
||||
// village props
|
||||
case "well": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.32).toFixed(1)}" fill="#7c7066" stroke="#3a3026" stroke-width="${(gs * 0.05).toFixed(1)}"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.18).toFixed(1)}" fill="#1c2a30"/></g>`;
|
||||
case "fountain": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.4).toFixed(1)}" fill="#9aa0a8" stroke="#5a5f66" stroke-width="${(gs * 0.05).toFixed(1)}"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.26).toFixed(1)}" fill="${VILLAGE.water}"/><circle cx="${cx}" cy="${cy}" r="${(gs * 0.07).toFixed(1)}" fill="#cfe6f0"/></g>`;
|
||||
case "stall": return `<g><rect x="${(cx - gs * 0.38).toFixed(1)}" y="${(cy - gs * 0.3).toFixed(1)}" width="${(gs * 0.76).toFixed(1)}" height="${(gs * 0.6).toFixed(1)}" rx="${(gs * 0.04).toFixed(1)}" fill="#8a6b4a"/><rect x="${(cx - gs * 0.42).toFixed(1)}" y="${(cy - gs * 0.34).toFixed(1)}" width="${(gs * 0.84).toFixed(1)}" height="${(gs * 0.26).toFixed(1)}" rx="${(gs * 0.03).toFixed(1)}" fill="${p.color}"/><line x1="${(cx - gs * 0.42).toFixed(1)}" y1="${(cy - gs * 0.34).toFixed(1)}" x2="${(cx - gs * 0.42).toFixed(1)}" y2="${(cy - gs * 0.08).toFixed(1)}" stroke="#fff" stroke-width="${(gs * 0.04).toFixed(1)}" opacity="0.5"/></g>`;
|
||||
case "cart": return `<g><rect x="${(cx - gs * 0.3).toFixed(1)}" y="${(cy - gs * 0.2).toFixed(1)}" width="${(gs * 0.6).toFixed(1)}" height="${(gs * 0.4).toFixed(1)}" rx="${(gs * 0.03).toFixed(1)}" fill="#7a5236" stroke="#3a2614" stroke-width="${(gs * 0.02).toFixed(1)}"/><circle cx="${(cx - gs * 0.22).toFixed(1)}" cy="${(cy + gs * 0.24).toFixed(1)}" r="${(gs * 0.1).toFixed(1)}" fill="#3a2614"/><circle cx="${(cx + gs * 0.22).toFixed(1)}" cy="${(cy + gs * 0.24).toFixed(1)}" r="${(gs * 0.1).toFixed(1)}" fill="#3a2614"/></g>`;
|
||||
case "barrel": return `<circle cx="${cx}" cy="${cy}" r="${(gs * 0.14).toFixed(1)}" fill="#7a5236" stroke="#3a2614" stroke-width="${(gs * 0.025).toFixed(1)}"/>`;
|
||||
case "haybale": return `<rect x="${(cx - gs * 0.18).toFixed(1)}" y="${(cy - gs * 0.14).toFixed(1)}" width="${(gs * 0.36).toFixed(1)}" height="${(gs * 0.28).toFixed(1)}" rx="${(gs * 0.06).toFixed(1)}" fill="#c7aa52" stroke="#9a7a3a" stroke-width="${(gs * 0.02).toFixed(1)}"/>`;
|
||||
case "bush": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.18).toFixed(1)}" fill="#3a5e2a"/><circle cx="${(cx - gs * 0.08).toFixed(1)}" cy="${(cy - gs * 0.06).toFixed(1)}" r="${(gs * 0.1).toFixed(1)}" fill="#4a7a38"/></g>`;
|
||||
case "flowers": return `<g><circle cx="${cx}" cy="${cy}" r="${(gs * 0.16).toFixed(1)}" fill="#4a7a38"/><circle cx="${(cx - gs * 0.06).toFixed(1)}" cy="${cy}" r="${(gs * 0.04).toFixed(1)}" fill="${p.color}"/><circle cx="${(cx + gs * 0.05).toFixed(1)}" cy="${(cy - gs * 0.05).toFixed(1)}" r="${(gs * 0.04).toFixed(1)}" fill="#e8d24a"/><circle cx="${(cx + gs * 0.04).toFixed(1)}" cy="${(cy + gs * 0.06).toFixed(1)}" r="${(gs * 0.04).toFixed(1)}" fill="#e85a7a"/></g>`;
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ VILLAGE =====================================
|
||||
function renderVillage(out, m, gs, W, H, rng) {
|
||||
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${VILLAGE.grass}"/>`);
|
||||
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(`<path d="${d}" fill="none" stroke="${VILLAGE.roadEdge}" stroke-width="${(wpx + gs * 0.14).toFixed(1)}" stroke-linecap="round" stroke-linejoin="round"/>`);
|
||||
out.push(`<path d="${d}" fill="none" stroke="${VILLAGE.road}" stroke-width="${wpx.toFixed(1)}" stroke-linecap="round" stroke-linejoin="round"/>`);
|
||||
out.push(`<path d="${d}" fill="none" stroke="${VILLAGE.cobble}" stroke-width="${wpx.toFixed(1)}" stroke-linecap="round" stroke-dasharray="${(gs * 0.18).toFixed(1)} ${(gs * 0.16).toFixed(1)}" opacity="0.3"/>`);
|
||||
}
|
||||
if (m.square) out.push(`<rect x="${PX(m.square.x, gs)}" y="${PX(m.square.y, gs)}" width="${PX(m.square.w, gs)}" height="${PX(m.square.h, gs)}" rx="${(gs * 0.1).toFixed(1)}" fill="${VILLAGE.square}" opacity="0.95"/>`);
|
||||
|
||||
if (m.water) {
|
||||
const d = "M" + m.water.loop.map((p) => `${PX(p.x, gs)},${PX(p.y, gs)}`).join(" L") + "Z";
|
||||
out.push(`<path d="${d}" fill="${VILLAGE.water}" stroke="${VILLAGE.waterEdge}" stroke-width="${(gs * 0.06).toFixed(1)}"/>`);
|
||||
}
|
||||
|
||||
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(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${VILLAGE.garden[p.crop ?? 0]}" stroke="${VILLAGE.fence}" stroke-width="${(gs * 0.03).toFixed(1)}" stroke-dasharray="${(gs * 0.12).toFixed(1)} ${(gs * 0.08).toFixed(1)}"/>`);
|
||||
for (let r = 0; r < p.h * 2; r += 1) out.push(`<line x1="${x}" y1="${PX(p.y + r / 2, gs)}" x2="${(x + w).toFixed(1)}" y2="${PX(p.y + r / 2, gs)}" stroke="${VILLAGE.gardenRow}" stroke-width="${(gs * 0.015).toFixed(1)}" opacity="0.5"/>`);
|
||||
}
|
||||
|
||||
// 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(`<g>`);
|
||||
for (const t of trees) out.push(treeSVG(t.x, t.y, t.r, t.green ?? 0, gs));
|
||||
out.push(`</g>`);
|
||||
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(`<rect x="${(x + off).toFixed(1)}" y="${(y + off).toFixed(1)}" width="${w}" height="${h}" rx="${(gs * 0.04).toFixed(1)}" fill="#000" opacity="0.3"/>`);
|
||||
out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${(gs * 0.03).toFixed(1)}" fill="${b.roof}" stroke="${VILLAGE.roofTrim}" stroke-width="${(gs * 0.05).toFixed(1)}"/>`);
|
||||
// ridge line + thatch/tile rows
|
||||
if (b.w >= b.h) {
|
||||
const my = y + h / 2;
|
||||
out.push(`<line x1="${(x + gs * 0.12).toFixed(1)}" y1="${my.toFixed(1)}" x2="${(x + w - gs * 0.12).toFixed(1)}" y2="${my.toFixed(1)}" stroke="#fff" stroke-width="${(gs * 0.03).toFixed(1)}" opacity="0.18"/>`);
|
||||
for (let i = 1; i < b.w; i += 1) out.push(`<line x1="${PX(b.x + i, gs)}" y1="${y}" x2="${PX(b.x + i, gs)}" y2="${(y + h).toFixed(1)}" stroke="${VILLAGE.roofTrim}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.3"/>`);
|
||||
} else {
|
||||
const mx = x + w / 2;
|
||||
out.push(`<line x1="${mx.toFixed(1)}" y1="${(y + gs * 0.12).toFixed(1)}" x2="${mx.toFixed(1)}" y2="${(y + h - gs * 0.12).toFixed(1)}" stroke="#fff" stroke-width="${(gs * 0.03).toFixed(1)}" opacity="0.18"/>`);
|
||||
for (let i = 1; i < b.h; i += 1) out.push(`<line x1="${x}" y1="${PX(b.y + i, gs)}" x2="${(x + w).toFixed(1)}" y2="${PX(b.y + i, gs)}" stroke="${VILLAGE.roofTrim}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.3"/>`);
|
||||
}
|
||||
if (b.chimney) out.push(`<rect x="${(x + w * 0.72).toFixed(1)}" y="${(y + h * 0.16).toFixed(1)}" width="${(gs * 0.28).toFixed(1)}" height="${(gs * 0.28).toFixed(1)}" fill="#5a4636" stroke="#2a1d12" stroke-width="${(gs * 0.02).toFixed(1)}"/>`);
|
||||
}
|
||||
|
||||
// ============================ 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 `<g><circle cx="${(cx + gs * 0.06).toFixed(1)}" cy="${(cy + gs * 0.08).toFixed(1)}" r="${rr.toFixed(1)}" fill="#000" opacity="0.22"/>` +
|
||||
`<circle cx="${cx}" cy="${cy}" r="${rr.toFixed(1)}" fill="${g[0]}"/>` +
|
||||
`<circle cx="${(cx - rr * 0.25).toFixed(1)}" cy="${(cy - rr * 0.25).toFixed(1)}" r="${(rr * 0.6).toFixed(1)}" fill="${g[1]}" opacity="0.9"/></g>`;
|
||||
}
|
||||
|
||||
function speckle(W, H, gs, color, opacity, density, rng) {
|
||||
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.015).toFixed(1)}" opacity="${opacity}">`];
|
||||
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(`<line x1="${x.toFixed(1)}" y1="${y.toFixed(1)}" x2="${(x + Math.cos(a) * l).toFixed(1)}" y2="${(y + Math.sin(a) * l).toFixed(1)}"/>`); }
|
||||
return out.join("") + `</g>`;
|
||||
}
|
||||
|
||||
function grassPatches(m, gs, rng) {
|
||||
const out = [`<g>`];
|
||||
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(`<ellipse cx="${PX(x, gs)}" cy="${PX(y, gs)}" rx="${rx.toFixed(1)}" ry="${(rx * 0.7).toFixed(1)}" fill="${VILLAGE.grassAlt}" opacity="0.3"/>`); }
|
||||
return out.join("") + `</g>`;
|
||||
}
|
||||
|
||||
function grassTufts(m, gs, rng) {
|
||||
const out = [`<g stroke="${VILLAGE.tuft}" stroke-width="${(gs * 0.02).toFixed(1)}" stroke-linecap="round" opacity="0.45">`];
|
||||
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(`<path d="M${x},${y} l${(-hh * 0.3).toFixed(1)},${(-hh).toFixed(1)} M${x},${y} l${(hh * 0.3).toFixed(1)},${(-hh * 0.9).toFixed(1)}"/>`); }
|
||||
return out.join("") + `</g>`;
|
||||
}
|
||||
|
||||
function grid(m, gs, color) {
|
||||
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.2">`];
|
||||
for (let x = 0; x <= m.cols; x += 1) out.push(`<line x1="${PX(x, gs)}" y1="0" x2="${PX(x, gs)}" y2="${PX(m.rows, gs)}"/>`);
|
||||
for (let y = 0; y <= m.rows; y += 1) out.push(`<line x1="0" y1="${PX(y, gs)}" x2="${PX(m.cols, gs)}" y2="${PX(y, gs)}"/>`);
|
||||
return out.join("") + `</g>`;
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c])); }
|
||||
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`);
|
||||
|
||||
// --- defs: shadow filter + clip to floor ---------------------------------
|
||||
out.push(`<defs>`);
|
||||
out.push(
|
||||
`<filter id="mw-wallshadow" x="-20%" y="-20%" width="140%" height="140%">` +
|
||||
`<feDropShadow dx="0" dy="0" stdDeviation="${(gs * 0.06).toFixed(1)}" flood-color="#000000" flood-opacity="0.45"/>` +
|
||||
`</filter>`
|
||||
);
|
||||
out.push(`<clipPath id="mw-floorclip"><path d="${floorPath}"/></clipPath>`);
|
||||
out.push(`</defs>`);
|
||||
|
||||
// --- outside ground ------------------------------------------------------
|
||||
out.push(`<rect x="0" y="0" width="${W}" height="${H}" fill="${theme.outside}"/>`);
|
||||
|
||||
// --- exterior hatching (drawn under the floor so it tucks beneath walls) --
|
||||
out.push(`<g stroke="${theme.hatch}" stroke-width="${(gs * 0.03).toFixed(1)}" stroke-linecap="round" opacity="0.5">`);
|
||||
out.push(exteriorHatching(segs, region, cols, rows, gs, rng));
|
||||
out.push(`</g>`);
|
||||
|
||||
// --- floor base + texture + furniture (clipped to footprint) -------------
|
||||
out.push(`<g clip-path="url(#mw-floorclip)">`);
|
||||
out.push(`<path d="${floorPath}" fill="${theme.floorBase}"/>`);
|
||||
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(`</g>`);
|
||||
|
||||
// --- walls (with cast shadow) --------------------------------------------
|
||||
const wallW = (gs * 0.17).toFixed(1);
|
||||
out.push(`<g filter="url(#mw-wallshadow)">`);
|
||||
out.push(`<g stroke="${theme.wall}" stroke-width="${wallW}" stroke-linecap="butt" fill="none">`);
|
||||
for (const s of segs) {
|
||||
if (s.kind !== "wall") continue;
|
||||
out.push(`<line x1="${PX(s.x1, gs)}" y1="${PX(s.y1, gs)}" x2="${PX(s.x2, gs)}" y2="${PX(s.y2, gs)}"/>`);
|
||||
}
|
||||
out.push(`</g></g>`);
|
||||
|
||||
// --- 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(`<g font-family="'Segoe UI', 'Helvetica Neue', sans-serif" text-anchor="middle">`);
|
||||
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(
|
||||
`<text x="${PX(room.cx, gs)}" y="${ly.toFixed(1)}" fill="${theme.label}" stroke="${theme.floorBase}" stroke-width="${(fontSize * 0.18).toFixed(1)}" paint-order="stroke" ` +
|
||||
`font-size="${fontSize.toFixed(1)}" font-weight="700" opacity="0.85">${escapeXml(room.label.toUpperCase())}</text>`
|
||||
);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
}
|
||||
|
||||
out.push(`</svg>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/** Draw furniture placements (same geometry the locked Tiles use). */
|
||||
function drawPlacements(placements, subtype) {
|
||||
let out = `<g stroke-linejoin="round">`;
|
||||
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 += `<g transform="translate(${tx},${ty}) rotate(${p.rotation}, ${(p.wpx / 2).toFixed(1)}, ${(p.hpx / 2).toFixed(1)})">${inner}</g>`;
|
||||
}
|
||||
return out + `</g>`;
|
||||
}
|
||||
|
||||
/** 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(`<g stroke="${theme.floorSeam}" stroke-width="${(gs * 0.015).toFixed(1)}" opacity="0.6">`);
|
||||
for (let x = fp.x; x <= fp.x + fp.w; x += 1) {
|
||||
lines.push(`<line x1="${PX(x, gs)}" y1="${PX(fp.y, gs)}" x2="${PX(x, gs)}" y2="${PX(fp.y + fp.h, gs)}"/>`);
|
||||
}
|
||||
for (let y = fp.y; y <= fp.y + fp.h; y += 1) {
|
||||
lines.push(`<line x1="${PX(fp.x, gs)}" y1="${PX(y, gs)}" x2="${PX(fp.x + fp.w, gs)}" y2="${PX(y, gs)}"/>`);
|
||||
}
|
||||
lines.push(`</g>`);
|
||||
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 = [`<g stroke="${theme.floorSeam}" stroke-width="${(gs * 0.022).toFixed(1)}" fill="none" opacity="0.55" stroke-linejoin="round">`];
|
||||
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(`<path d="M${p(x, y)} L${p(x + 1, y)} L${p(x + 1, y + 1)} L${p(x, y + 1)} Z"/>`);
|
||||
}
|
||||
}
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function gridOverlay(layout, gs, color) {
|
||||
const fp = layout.footprint;
|
||||
const out = [`<g stroke="${color}" stroke-width="${(gs * 0.01).toFixed(1)}" opacity="0.22">`];
|
||||
for (let x = fp.x; x <= fp.x + fp.w; x += 1) {
|
||||
out.push(`<line x1="${PX(x, gs)}" y1="${PX(fp.y, gs)}" x2="${PX(x, gs)}" y2="${PX(fp.y + fp.h, gs)}"/>`);
|
||||
}
|
||||
for (let y = fp.y; y <= fp.y + fp.h; y += 1) {
|
||||
out.push(`<line x1="${PX(fp.x, gs)}" y1="${PX(y, gs)}" x2="${PX(fp.x + fp.w, gs)}" y2="${PX(y, gs)}"/>`);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
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(`<line x1="${px.toFixed(1)}" y1="${py.toFixed(1)}" x2="${ex.toFixed(1)}" y2="${ey.toFixed(1)}"/>`);
|
||||
}
|
||||
}
|
||||
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 = [`<g>`];
|
||||
|
||||
// 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(
|
||||
`<line x1="${(ex - ppx * jamb / 2).toFixed(1)}" y1="${(ey - ppy * jamb / 2).toFixed(1)}" ` +
|
||||
`x2="${(ex + ppx * jamb / 2).toFixed(1)}" y2="${(ey + ppy * jamb / 2).toFixed(1)}" ` +
|
||||
`stroke="${theme.doorFrame}" stroke-width="${(gs * 0.075).toFixed(1)}" stroke-linecap="round"/>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
`<path d="M${ox.toFixed(1)},${oy.toFixed(1)} A${leafLen.toFixed(1)},${leafLen.toFixed(1)} 0 0 ${sign > 0 ? 1 : 0} ${lx.toFixed(1)},${ly.toFixed(1)}" ` +
|
||||
`fill="none" stroke="${theme.doorLeaf}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.35" ` +
|
||||
`stroke-dasharray="${(gs * 0.05).toFixed(1)} ${(gs * 0.05).toFixed(1)}"/>`
|
||||
);
|
||||
|
||||
// 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(
|
||||
`<path d="M${pt(hx, hy, th / 2, 0)} L${pt(hx, hy, th / 2, leafLen)} L${pt(hx, hy, -th / 2, leafLen)} L${pt(hx, hy, -th / 2, 0)} Z" ` +
|
||||
`fill="${theme.doorFill}" stroke="${theme.doorLeaf}" stroke-width="${(gs * 0.018).toFixed(1)}"/>`
|
||||
);
|
||||
if (subtype === "fantasy") {
|
||||
for (const f of [0.33, 0.66]) {
|
||||
out.push(`<line x1="${pt(hx, hy, th / 2, leafLen * f).split(",")[0]}" y1="${pt(hx, hy, th / 2, leafLen * f).split(",")[1]}" x2="${pt(hx, hy, -th / 2, leafLen * f).split(",")[0]}" y2="${pt(hx, hy, -th / 2, leafLen * f).split(",")[1]}" stroke="${theme.doorLeaf}" stroke-width="${(gs * 0.012).toFixed(1)}" opacity="0.6"/>`);
|
||||
}
|
||||
} else {
|
||||
out.push(`<line x1="${pt(hx, hy, 0, leafLen * 0.12).split(",")[0]}" y1="${pt(hx, hy, 0, leafLen * 0.12).split(",")[1]}" x2="${pt(hx, hy, 0, leafLen * 0.88).split(",")[0]}" y2="${pt(hx, hy, 0, leafLen * 0.88).split(",")[1]}" stroke="${theme.window}" stroke-width="${(th * 0.4).toFixed(1)}" opacity="0.6" stroke-linecap="round"/>`);
|
||||
}
|
||||
// Handle + hinge dot.
|
||||
const handle = pt(hx, hy, 0, leafLen * 0.82).split(",");
|
||||
out.push(`<circle cx="${handle[0]}" cy="${handle[1]}" r="${(gs * 0.028).toFixed(1)}" fill="#c9a227"/>`);
|
||||
out.push(`<circle cx="${hx.toFixed(1)}" cy="${hy.toFixed(1)}" r="${(gs * 0.03).toFixed(1)}" fill="${theme.doorFrame}"/>`);
|
||||
|
||||
if (s.locked) {
|
||||
const mx = (hx + ox) / 2, my = (hy + oy) / 2;
|
||||
out.push(
|
||||
`<circle cx="${mx.toFixed(1)}" cy="${my.toFixed(1)}" r="${(gs * 0.07).toFixed(1)}" fill="#d4a017" stroke="#3a2c00" stroke-width="${(gs * 0.015).toFixed(1)}"/>` +
|
||||
`<rect x="${(mx - gs * 0.016).toFixed(1)}" y="${(my - gs * 0.004).toFixed(1)}" width="${(gs * 0.032).toFixed(1)}" height="${(gs * 0.04).toFixed(1)}" fill="#3a2c00"/>`
|
||||
);
|
||||
}
|
||||
out.push(`</g>`);
|
||||
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 = [`<g>`];
|
||||
|
||||
// Frame band, then glass band.
|
||||
out.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${theme.windowFrame}" stroke-width="${(sill * 1.1).toFixed(1)}" stroke-linecap="butt"/>`);
|
||||
out.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${theme.window}" stroke-width="${(sill * 0.62).toFixed(1)}" stroke-linecap="butt" opacity="0.92"/>`);
|
||||
// Highlight streak.
|
||||
out.push(`<line x1="${(x1 + px * sill * 0.14).toFixed(1)}" y1="${(y1 + py * sill * 0.14).toFixed(1)}" x2="${(x2 + px * sill * 0.14).toFixed(1)}" y2="${(y2 + py * sill * 0.14).toFixed(1)}" stroke="#ffffff" stroke-width="${(gs * 0.01).toFixed(1)}" opacity="0.5"/>`);
|
||||
|
||||
const bar = (f) => {
|
||||
const bx = x1 + (x2 - x1) * f, by = y1 + (y2 - y1) * f;
|
||||
out.push(`<line x1="${(bx - px * sill * 0.55).toFixed(1)}" y1="${(by - py * sill * 0.55).toFixed(1)}" x2="${(bx + px * sill * 0.55).toFixed(1)}" y2="${(by + py * sill * 0.55).toFixed(1)}" stroke="${theme.windowFrame}" stroke-width="${(gs * 0.02).toFixed(1)}"/>`);
|
||||
};
|
||||
if (subtype === "fantasy") {
|
||||
bar(0.33); bar(0.66); // leaded panes
|
||||
} else {
|
||||
bar(0.5); // single mullion
|
||||
}
|
||||
out.push(`</g>`);
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function escapeXml(str) {
|
||||
return String(str).replace(/[<>&'"]/g, (c) => (
|
||||
{ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]
|
||||
));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Foundry-facing scene assembly (generic across all generators).
|
||||
*
|
||||
* Takes a uniform MapResult (svg + wall segments + notes + lights) and produces
|
||||
* a real Foundry scene with a raster background. Everything is flagged for
|
||||
* later cleanup. Uses Foundry globals — browser only.
|
||||
*
|
||||
* MapResult shape:
|
||||
* { width, height, gridSize, backgroundColor, segments, svg, notes, lights }
|
||||
*/
|
||||
|
||||
import { PIECES, pieceSpriteSVG } from "./furniture.mjs";
|
||||
|
||||
const MODULE_ID = "mapwright";
|
||||
const SPRITE_PPC = 128; // sprite resolution per grid cell
|
||||
const _spriteCache = new Set(); // paths known to exist this session
|
||||
|
||||
const C = () => ({
|
||||
MOVE_NORMAL: CONST.WALL_MOVEMENT_TYPES?.NORMAL ?? 20,
|
||||
SENSE_NONE: CONST.WALL_SENSE_TYPES?.NONE ?? 0,
|
||||
SENSE_NORMAL: CONST.WALL_SENSE_TYPES?.NORMAL ?? 20,
|
||||
DOOR_NONE: CONST.WALL_DOOR_TYPES?.NONE ?? 0,
|
||||
DOOR_BASIC: CONST.WALL_DOOR_TYPES?.DOOR ?? 1,
|
||||
DS_CLOSED: CONST.WALL_DOOR_STATES?.CLOSED ?? 0,
|
||||
DS_LOCKED: CONST.WALL_DOOR_STATES?.LOCKED ?? 2,
|
||||
GRID_SQUARE: CONST.GRID_TYPES?.SQUARE ?? 1
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {object} result MapResult
|
||||
* @param {object} meta { name, gridSize, seed, subtype, tokenVision }
|
||||
* @returns {Promise<Scene>}
|
||||
*/
|
||||
export async function createScene(result, meta) {
|
||||
const k = C();
|
||||
const gs = result.gridSize ?? meta.gridSize ?? 70;
|
||||
const W = result.width;
|
||||
const H = result.height;
|
||||
|
||||
const filename = `${slug(meta.name)}-${Date.now()}.png`;
|
||||
const bgPath = await rasterAndUpload(result.svg, W, H, filename);
|
||||
|
||||
const bgColor = result.backgroundColor ?? "#000000";
|
||||
const sceneData = {
|
||||
name: meta.name,
|
||||
width: W,
|
||||
height: H,
|
||||
padding: 0.0,
|
||||
grid: { type: k.GRID_SQUARE, size: gs },
|
||||
// Default: a flat, fully-visible battlemap that matches the preview.
|
||||
// Dynamic lighting (token vision + ambient lights) is opt-in.
|
||||
tokenVision: meta.tokenVision ?? false,
|
||||
flags: { [MODULE_ID]: { generated: true, seed: meta.seed, subtype: meta.subtype } }
|
||||
};
|
||||
if (meta.folderId) sceneData.folder = meta.folderId;
|
||||
const scene = await Scene.create(sceneData);
|
||||
|
||||
await applyBackground(scene, bgPath, bgColor);
|
||||
|
||||
const wallDocs = (result.segments ?? []).map((s) => segmentToWall(s, gs, k));
|
||||
if (wallDocs.length) await scene.createEmbeddedDocuments("Wall", wallDocs);
|
||||
|
||||
if (result.lights?.length) await scene.createEmbeddedDocuments("AmbientLight", result.lights);
|
||||
if (result.notes?.length) await scene.createEmbeddedDocuments("Note", result.notes);
|
||||
if (result.placements?.length) await createFurnitureTiles(scene, result.placements, meta.subtype);
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multi-floor building as a folder of linked scenes.
|
||||
* @param {object[]} results one MapResult per floor (each may carry floorName)
|
||||
* @param {object} meta { name, gridSize, seed, subtype }
|
||||
* @returns {Promise<Scene[]>}
|
||||
*/
|
||||
export async function createSceneSet(results, meta) {
|
||||
const folder = await Folder.create({ name: meta.name, type: "Scene" });
|
||||
const scenes = [];
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
const scene = await createScene(r, {
|
||||
...meta,
|
||||
name: `${meta.name} — ${r.floorName ?? `Floor ${i + 1}`}`,
|
||||
folderId: folder.id
|
||||
});
|
||||
scenes.push(scene);
|
||||
}
|
||||
if (scenes[0]) await scenes[0].view?.();
|
||||
return scenes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the background image to a scene, handling the v14 Levels architecture.
|
||||
*
|
||||
* In Foundry v14 `Scene#background` is deprecated — the image lives on the
|
||||
* scene's default Level (`scene.levels` → `level.background.src`). On v12/v13
|
||||
* the image is the scene's own `background.src`. This sets whichever applies.
|
||||
*/
|
||||
async function applyBackground(scene, src, color) {
|
||||
if (!scene) return false;
|
||||
|
||||
// v14+: background lives on the scene's (auto-created) default Level.
|
||||
const levels = scene.levels;
|
||||
if (levels && (levels.size ?? levels.length)) {
|
||||
const first = levels.get?.(scene.initialLevel?._id ?? scene.initialLevel) ?? [...levels][0];
|
||||
if (first) {
|
||||
await scene.updateEmbeddedDocuments("Level", [{ _id: first.id, background: { src, color } }]);
|
||||
const ok = scene.levels.get(first.id)?.background?.src === src;
|
||||
if (ok) console.log(`Mapwright: scene "${scene.name}" background set on level → ${src}`);
|
||||
else console.warn(`Mapwright: level background did not apply (wanted "${src}").`);
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
// v12/v13 fallback: background on the scene document itself.
|
||||
await scene.update({ background: { src, color } });
|
||||
const ok = scene.background?.src === src;
|
||||
if (!ok) {
|
||||
console.warn(`Mapwright: scene background did not apply (got "${scene.background?.src}", wanted "${src}").`);
|
||||
ui.notifications?.warn?.("Mapwright: map image uploaded but the scene background didn't apply — see console (F12).");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function segmentToWall(s, gs, k) {
|
||||
const c = [
|
||||
Math.round(s.x1 * gs), Math.round(s.y1 * gs),
|
||||
Math.round(s.x2 * gs), Math.round(s.y2 * gs)
|
||||
];
|
||||
const base = {
|
||||
c,
|
||||
move: k.MOVE_NORMAL,
|
||||
sight: k.SENSE_NORMAL,
|
||||
sound: k.SENSE_NORMAL,
|
||||
light: k.SENSE_NORMAL,
|
||||
flags: { [MODULE_ID]: { generated: true, kind: s.kind } }
|
||||
};
|
||||
if (s.kind === "door") {
|
||||
base.door = k.DOOR_BASIC;
|
||||
base.ds = s.locked ? k.DS_LOCKED : k.DS_CLOSED;
|
||||
} else if (s.kind === "window") {
|
||||
base.sight = k.SENSE_NONE;
|
||||
base.light = k.SENSE_NONE;
|
||||
} else if (s.kind === "water") {
|
||||
// Deep water: blocks movement only; sight/sound/light pass freely.
|
||||
base.sight = k.SENSE_NONE;
|
||||
base.sound = k.SENSE_NONE;
|
||||
base.light = k.SENSE_NONE;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function filePicker() {
|
||||
return foundry.applications?.apps?.FilePicker?.implementation
|
||||
?? foundry.applications?.apps?.FilePicker
|
||||
?? FilePicker;
|
||||
}
|
||||
|
||||
/** Normalise an upload path to a clean relative form Foundry will accept. */
|
||||
function cleanPath(p) {
|
||||
return String(p ?? "")
|
||||
.replace(/^https?:\/\/[^/]+/i, "")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
/** Rasterise an SVG string to a PNG Blob (transparent where the SVG is). */
|
||||
async function svgToPngBlob(svg, w, h) {
|
||||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = new Image();
|
||||
img.width = w; img.height = h;
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error("SVG rasterisation failed"));
|
||||
img.src = url;
|
||||
});
|
||||
try { if (img.decode) await img.decode(); } catch (e) { /* best-effort */ }
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w; canvas.height = h;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
|
||||
const png = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!png) throw new Error("Mapwright: canvas.toBlob returned null.");
|
||||
return png;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBlob(blob, dir, filename) {
|
||||
const FP = filePicker();
|
||||
try { await FP.createDirectory("data", dir); } catch (e) { /* exists */ }
|
||||
const file = new File([blob], filename, { type: "image/png" });
|
||||
const upload = await FP.upload("data", dir, file, {}, { notify: false });
|
||||
if (!upload?.path) throw new Error("Mapwright: upload failed (no path). Check file-upload permission.");
|
||||
return cleanPath(upload.path);
|
||||
}
|
||||
|
||||
async function rasterAndUpload(svg, w, h, filename) {
|
||||
const png = await svgToPngBlob(svg, w, h);
|
||||
const path = await uploadBlob(png, MODULE_ID, filename);
|
||||
console.log(`Mapwright: background uploaded → "${path}" (${Math.round(png.size / 1024)} KB)`);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Ensure a furniture sprite PNG exists; returns its served path. Cached. */
|
||||
async function ensureSprite(kind, subtype) {
|
||||
const filename = `${subtype}-${kind}.png`;
|
||||
const path = `${MODULE_ID}/furniture/${filename}`;
|
||||
if (_spriteCache.has(path)) return path;
|
||||
// Check if already uploaded (persists across sessions).
|
||||
try {
|
||||
const listing = await filePicker().browse("data", `${MODULE_ID}/furniture`);
|
||||
if (listing?.files?.some((f) => cleanPath(f).endsWith(filename))) { _spriteCache.add(path); return path; }
|
||||
} catch (e) { /* dir may not exist yet */ }
|
||||
const def = PIECES[kind];
|
||||
if (!def) return null;
|
||||
const [pw, ph] = def.footprint;
|
||||
const w = pw * SPRITE_PPC, h = ph * SPRITE_PPC;
|
||||
const png = await svgToPngBlob(pieceSpriteSVG(kind, subtype, w, h), w, h);
|
||||
const uploaded = await uploadBlob(png, `${MODULE_ID}/furniture`, filename);
|
||||
_spriteCache.add(uploaded);
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
/** Create locked furniture Tiles from placements. */
|
||||
async function createFurnitureTiles(scene, placements, subtype) {
|
||||
if (!placements?.length) return;
|
||||
// Resolve sprite paths (unique per kind).
|
||||
const paths = {};
|
||||
for (const kind of new Set(placements.map((p) => p.kind))) {
|
||||
try { paths[kind] = await ensureSprite(kind, subtype); } catch (e) { console.warn(`Mapwright: sprite for "${kind}" failed`, e); }
|
||||
}
|
||||
const tiles = [];
|
||||
for (const p of placements) {
|
||||
const src = paths[p.kind];
|
||||
if (!src) continue;
|
||||
tiles.push({
|
||||
texture: { src },
|
||||
x: Math.round(p.cx - p.wpx / 2),
|
||||
y: Math.round(p.cy - p.hpx / 2),
|
||||
width: Math.round(p.wpx),
|
||||
height: Math.round(p.hpx),
|
||||
rotation: p.rotation ?? 0,
|
||||
locked: true,
|
||||
sort: 0,
|
||||
flags: { [MODULE_ID]: { generated: true, kind: p.kind } }
|
||||
});
|
||||
}
|
||||
if (tiles.length) await scene.createEmbeddedDocuments("Tile", tiles);
|
||||
}
|
||||
|
||||
function slug(str) {
|
||||
return String(str || "map").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "map";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = `<i class="fas fa-map"></i> ${game.i18n.localize("MAPWRIGHT.SceneButton")}`;
|
||||
btn.addEventListener("click", () => MapwrightApp.open());
|
||||
header.appendChild(btn);
|
||||
});
|
||||
Reference in New Issue
Block a user