mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-22 15:17:23 +01:00
624 lines
18 KiB
TypeScript
624 lines
18 KiB
TypeScript
import Alea from "alea";
|
|
import { color, quadtree } from "d3";
|
|
import Delaunator from "delaunator";
|
|
import {
|
|
type Cells,
|
|
type Point,
|
|
type Vertices,
|
|
Voronoi,
|
|
} from "../modules/voronoi";
|
|
import type { PackedGraph } from "../types/PackedGraph";
|
|
import { createTypedArray } from "./arrayUtils";
|
|
import { rn } from "./numberUtils";
|
|
import { byId } from "./shorthands";
|
|
|
|
/**
|
|
* Get boundary points on a regular square grid
|
|
* @param {number} width - The width of the area
|
|
* @param {number} height - The height of the area
|
|
* @param {number} spacing - The spacing between points
|
|
* @returns {Array} - An array of boundary points
|
|
*/
|
|
const getBoundaryPoints = (
|
|
width: number,
|
|
height: number,
|
|
spacing: number,
|
|
): Point[] => {
|
|
const offset = rn(-1 * spacing);
|
|
const bSpacing = spacing * 2;
|
|
const w = width - offset * 2;
|
|
const h = height - offset * 2;
|
|
const numberX = Math.ceil(w / bSpacing) - 1;
|
|
const numberY = Math.ceil(h / bSpacing) - 1;
|
|
const points: Point[] = [];
|
|
|
|
for (let i = 0.5; i < numberX; i++) {
|
|
const x = Math.ceil((w * i) / numberX + offset);
|
|
points.push([x, offset], [x, h + offset]);
|
|
}
|
|
|
|
for (let i = 0.5; i < numberY; i++) {
|
|
const y = Math.ceil((h * i) / numberY + offset);
|
|
points.push([offset, y], [w + offset, y]);
|
|
}
|
|
|
|
return points;
|
|
};
|
|
|
|
/**
|
|
* Get points on a jittered square grid
|
|
* @param {number} width - The width of the area
|
|
* @param {number} height - The height of the area
|
|
* @param {number} spacing - The spacing between points
|
|
* @returns {Array} - An array of jittered grid points
|
|
*/
|
|
const getJitteredGrid = (
|
|
width: number,
|
|
height: number,
|
|
spacing: number,
|
|
): Point[] => {
|
|
const radius = spacing / 2; // square radius
|
|
const jittering = radius * 0.9; // max deviation
|
|
const doubleJittering = jittering * 2;
|
|
const jitter = () => Math.random() * doubleJittering - jittering;
|
|
|
|
const points: Point[] = [];
|
|
for (let y = radius; y < height; y += spacing) {
|
|
for (let x = radius; x < width; x += spacing) {
|
|
const xj = Math.min(rn(x + jitter(), 2), width);
|
|
const yj = Math.min(rn(y + jitter(), 2), height);
|
|
points.push([xj, yj]);
|
|
}
|
|
}
|
|
return points;
|
|
};
|
|
|
|
/**
|
|
* Places points on a jittered grid and calculates spacing and cell counts
|
|
* @param {number} graphWidth - The width of the graph
|
|
* @param {number} graphHeight - The height of the graph
|
|
* @returns {Object} - An object containing spacing, cellsDesired, boundary points, grid points, cellsX, and cellsY
|
|
*/
|
|
const placePoints = (
|
|
graphWidth: number,
|
|
graphHeight: number,
|
|
): {
|
|
spacing: number;
|
|
cellsDesired: number;
|
|
boundary: Point[];
|
|
points: Point[];
|
|
cellsX: number;
|
|
cellsY: number;
|
|
} => {
|
|
TIME && console.time("placePoints");
|
|
const cellsDesired = +(byId("pointsInput")?.dataset.cells || 0);
|
|
const spacing = rn(Math.sqrt((graphWidth * graphHeight) / cellsDesired), 2); // spacing between points before jittering
|
|
|
|
const boundary = getBoundaryPoints(graphWidth, graphHeight, spacing);
|
|
const points = getJitteredGrid(graphWidth, graphHeight, spacing); // points of jittered square grid
|
|
const cellCountX = Math.floor((graphWidth + 0.5 * spacing - 1e-10) / spacing); // number of cells in x direction
|
|
const cellCountY = Math.floor(
|
|
(graphHeight + 0.5 * spacing - 1e-10) / spacing,
|
|
); // number of cells in y direction
|
|
TIME && console.timeEnd("placePoints");
|
|
|
|
return {
|
|
spacing,
|
|
cellsDesired,
|
|
boundary,
|
|
points,
|
|
cellsX: cellCountX,
|
|
cellsY: cellCountY,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Checks if the grid needs to be regenerated based on desired parameters
|
|
* @param {Object} grid - The current grid object
|
|
* @param {number} expectedSeed - The expected seed value
|
|
* @param {number} graphWidth - The width of the graph
|
|
* @param {number} graphHeight - The height of the graph
|
|
* @returns {boolean} - True if the grid should be regenerated, false otherwise
|
|
*/
|
|
export const shouldRegenerateGrid = (
|
|
grid: any,
|
|
expectedSeed: number,
|
|
graphWidth: number,
|
|
graphHeight: number,
|
|
) => {
|
|
if (expectedSeed && expectedSeed !== grid.seed) return true;
|
|
|
|
const cellsDesired = +(byId("pointsInput")?.dataset?.cells || 0);
|
|
if (cellsDesired !== grid.cellsDesired) return true;
|
|
|
|
const newSpacing = rn(
|
|
Math.sqrt((graphWidth * graphHeight) / cellsDesired),
|
|
2,
|
|
);
|
|
const newCellsX = Math.floor(
|
|
(graphWidth + 0.5 * newSpacing - 1e-10) / newSpacing,
|
|
);
|
|
const newCellsY = Math.floor(
|
|
(graphHeight + 0.5 * newSpacing - 1e-10) / newSpacing,
|
|
);
|
|
|
|
return (
|
|
grid.spacing !== newSpacing ||
|
|
grid.cellsX !== newCellsX ||
|
|
grid.cellsY !== newCellsY
|
|
);
|
|
};
|
|
|
|
interface Grid {
|
|
spacing: number;
|
|
cellsDesired: number;
|
|
boundary: Point[];
|
|
points: Point[];
|
|
cellsX: number;
|
|
cellsY: number;
|
|
seed: string | number;
|
|
cells: Cells;
|
|
vertices: Vertices;
|
|
}
|
|
/**
|
|
* Generates a Voronoi grid based on jittered grid points
|
|
* @returns {Object} - The generated grid object containing spacing, cellsDesired, boundary, points, cellsX, cellsY, cells, vertices, and seed
|
|
*/
|
|
export const generateGrid = (
|
|
seed: string,
|
|
graphWidth: number,
|
|
graphHeight: number,
|
|
): Grid => {
|
|
Math.random = Alea(seed); // reset PRNG
|
|
const { spacing, cellsDesired, boundary, points, cellsX, cellsY } =
|
|
placePoints(graphWidth, graphHeight);
|
|
const { cells, vertices } = calculateVoronoi(points, boundary);
|
|
return {
|
|
spacing,
|
|
cellsDesired,
|
|
boundary,
|
|
points,
|
|
cellsX,
|
|
cellsY,
|
|
cells,
|
|
vertices,
|
|
seed,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Calculates the Voronoi diagram from given points and boundary
|
|
* @param {Array} points - The array of points for Voronoi calculation
|
|
* @param {Array} boundary - The boundary points to clip the Voronoi cells
|
|
* @returns {Object} - An object containing Voronoi cells and vertices
|
|
*/
|
|
export const calculateVoronoi = (
|
|
points: Point[],
|
|
boundary: Point[],
|
|
): { cells: Cells; vertices: Vertices } => {
|
|
TIME && console.time("calculateDelaunay");
|
|
const allPoints = points.concat(boundary);
|
|
const delaunay = Delaunator.from(allPoints);
|
|
TIME && console.timeEnd("calculateDelaunay");
|
|
|
|
TIME && console.time("calculateVoronoi");
|
|
const voronoi = new Voronoi(delaunay, allPoints, points.length);
|
|
|
|
const cells = voronoi.cells;
|
|
cells.i = createTypedArray({
|
|
maxValue: points.length,
|
|
length: points.length,
|
|
}).map((_, i) => i) as Uint32Array; // array of indexes
|
|
const vertices = voronoi.vertices;
|
|
TIME && console.timeEnd("calculateVoronoi");
|
|
|
|
return { cells, vertices };
|
|
};
|
|
|
|
/**
|
|
* Returns a cell index on a regular square grid based on x and y coordinates
|
|
* @param {number} x - The x coordinate
|
|
* @param {number} y - The y coordinate
|
|
* @param {Object} grid - The grid object containing spacing, cellsX, and cellsY
|
|
* @returns {number} - The index of the cell in the grid
|
|
*/
|
|
export const findGridCell = (x: number, y: number, grid: any): number => {
|
|
return (
|
|
Math.floor(Math.min(y / grid.spacing, grid.cellsY - 1)) * grid.cellsX +
|
|
Math.floor(Math.min(x / grid.spacing, grid.cellsX - 1))
|
|
);
|
|
};
|
|
|
|
/**
|
|
* return array of cell indexes in radius on a regular square grid
|
|
* @param {number} x - The x coordinate
|
|
* @param {number} y - The y coordinate
|
|
* @param {number} radius - The search radius
|
|
* @param {Object} grid - The grid object containing spacing, cellsX, and cellsY
|
|
* @returns {Array} - An array of cell indexes within the specified radius
|
|
*/
|
|
export const findGridAll = (
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
grid: any,
|
|
): number[] => {
|
|
const c = grid.cells.c;
|
|
let r = Math.floor(radius / grid.spacing);
|
|
let found = [findGridCell(x, y, grid)];
|
|
if (!r || radius === 1) return found;
|
|
if (r > 0) found = found.concat(c[found[0]]);
|
|
if (r > 1) {
|
|
let frontier = c[found[0]];
|
|
while (r > 1) {
|
|
const cycle = frontier.slice();
|
|
frontier = [];
|
|
cycle.forEach((s: number) => {
|
|
c[s].forEach((e: number) => {
|
|
if (found.indexOf(e) !== -1) return;
|
|
found.push(e);
|
|
frontier.push(e);
|
|
});
|
|
});
|
|
r--;
|
|
}
|
|
}
|
|
|
|
return found;
|
|
};
|
|
|
|
const quadtreeCache = new WeakMap<
|
|
object,
|
|
ReturnType<typeof quadtree<[number, number, number]>>
|
|
>();
|
|
|
|
/**
|
|
* Returns the index of the packed cell containing the given x and y coordinates
|
|
* @param {number} x - The x coordinate
|
|
* @param {number} y - The y coordinate
|
|
* @param {number} radius - The search radius (default is Infinity)
|
|
* @returns {number|undefined} - The index of the found cell or undefined if not found
|
|
*/
|
|
export const findClosestCell = (
|
|
x: number,
|
|
y: number,
|
|
radius = Infinity,
|
|
pack: { cells: { p: [number, number][] } },
|
|
): number | undefined => {
|
|
if (!pack.cells?.p) throw new Error("Pack cells not found");
|
|
let qTree = quadtreeCache.get(pack.cells.p);
|
|
if (!qTree) {
|
|
qTree = quadtree(pack.cells.p.map(([px, py], i) => [px, py, i]));
|
|
if (!qTree) throw new Error("Failed to create quadtree");
|
|
quadtreeCache.set(pack.cells.p, qTree);
|
|
}
|
|
const found = qTree.find(x, y, radius);
|
|
return found ? found[2] : undefined;
|
|
};
|
|
|
|
/**
|
|
* Searches a quadtree for all points within a given radius
|
|
* Based on https://bl.ocks.org/lwthatcher/b41479725e0ff2277c7ac90df2de2b5e
|
|
* @param {number} x - The x coordinate of the search center
|
|
* @param {number} y - The y coordinate of the search center
|
|
* @param {number} radius - The search radius
|
|
* @param {Object} quadtree - The D3 quadtree to search
|
|
* @returns {Array} - An array of found data points within the radius
|
|
*/
|
|
export const findAllInQuadtree = (
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
quadtree: any,
|
|
) => {
|
|
let dx: number, dy: number, d2: number;
|
|
|
|
const radiusSearchInit = (t: any, radius: number) => {
|
|
t.result = [];
|
|
t.x0 = t.x - radius;
|
|
t.y0 = t.y - radius;
|
|
t.x3 = t.x + radius;
|
|
t.y3 = t.y + radius;
|
|
t.radius = radius * radius;
|
|
};
|
|
|
|
const radiusSearchVisit = (t: any, d2: number) => {
|
|
t.node.data.scanned = true;
|
|
if (d2 < t.radius) {
|
|
while (t.node) {
|
|
t.result.push(t.node.data);
|
|
t.node.data.selected = true;
|
|
t.node = t.node.next;
|
|
}
|
|
}
|
|
};
|
|
|
|
class Quad {
|
|
node: any;
|
|
x0: number;
|
|
y0: number;
|
|
x1: number;
|
|
y1: number;
|
|
constructor(node: any, x0: number, y0: number, x1: number, y1: number) {
|
|
this.node = node;
|
|
this.x0 = x0;
|
|
this.y0 = y0;
|
|
this.x1 = x1;
|
|
this.y1 = y1;
|
|
}
|
|
}
|
|
|
|
const t: any = {
|
|
x,
|
|
y,
|
|
x0: quadtree._x0,
|
|
y0: quadtree._y0,
|
|
x3: quadtree._x1,
|
|
y3: quadtree._y1,
|
|
quads: [],
|
|
node: quadtree._root,
|
|
};
|
|
if (t.node) t.quads.push(new Quad(t.node, t.x0, t.y0, t.x3, t.y3));
|
|
radiusSearchInit(t, radius);
|
|
|
|
var _i = 0;
|
|
t.q = t.quads.pop();
|
|
while (t.q) {
|
|
_i++;
|
|
|
|
t.node = t.q.node;
|
|
t.x1 = t.q.x0;
|
|
t.y1 = t.q.y0;
|
|
t.x2 = t.q.x1;
|
|
t.y2 = t.q.y1;
|
|
|
|
// Stop searching if this quadrant can't contain a closer node.
|
|
if (!t.node || t.x1 > t.x3 || t.y1 > t.y3 || t.x2 < t.x0 || t.y2 < t.y0) {
|
|
t.q = t.quads.pop();
|
|
continue;
|
|
}
|
|
|
|
// Bisect the current quadrant.
|
|
if (t.node.length) {
|
|
t.node.explored = true;
|
|
const xm: number = (t.x1 + t.x2) / 2,
|
|
ym: number = (t.y1 + t.y2) / 2;
|
|
|
|
t.quads.push(
|
|
new Quad(t.node[3], xm, ym, t.x2, t.y2),
|
|
new Quad(t.node[2], t.x1, ym, xm, t.y2),
|
|
new Quad(t.node[1], xm, t.y1, t.x2, ym),
|
|
new Quad(t.node[0], t.x1, t.y1, xm, ym),
|
|
);
|
|
|
|
// Visit the closest quadrant first.
|
|
t.i = (+(y >= ym) << 1) | +(x >= xm);
|
|
if (t.i) {
|
|
t.q = t.quads[t.quads.length - 1];
|
|
t.quads[t.quads.length - 1] = t.quads[t.quads.length - 1 - t.i];
|
|
t.quads[t.quads.length - 1 - t.i] = t.q;
|
|
}
|
|
}
|
|
|
|
// Visit this point. (Visiting coincident points isn't necessary!)
|
|
else {
|
|
dx = x - +quadtree._x.call(null, t.node.data);
|
|
dy = y - +quadtree._y.call(null, t.node.data);
|
|
d2 = dx * dx + dy * dy;
|
|
radiusSearchVisit(t, d2);
|
|
}
|
|
t.q = t.quads.pop();
|
|
}
|
|
return t.result;
|
|
};
|
|
|
|
/**
|
|
* Returns an array of packed cell indexes within a specified radius from given x and y coordinates
|
|
* @param {number} x - The x coordinate
|
|
* @param {number} y - The y coordinate
|
|
* @param {number} radius - The search radius
|
|
* @param {Object} packedGraph - The packed graph containing cells with quadtree
|
|
* @returns {number[]} - An array of cell indexes within the radius
|
|
*/
|
|
export const findAllCellsInRadius = (
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
packedGraph: any,
|
|
): number[] => {
|
|
const q = quadtree<[number, number, number]>(
|
|
packedGraph.cells.p.map(
|
|
([px, py]: [number, number], i: number) =>
|
|
[px, py, i] as [number, number, number],
|
|
),
|
|
);
|
|
const found = findAllInQuadtree(x, y, radius, q);
|
|
return found.map((r: any) => r[2]);
|
|
};
|
|
|
|
/**
|
|
* Returns the polygon points for a packed cell given its index
|
|
* @param {number} i - The index of the packed cell
|
|
* @returns {Array} - An array of polygon points for the specified cell
|
|
*/
|
|
export const getPackPolygon = (cellIndex: number, packedGraph: any) => {
|
|
return packedGraph.cells.v[cellIndex].map(
|
|
(v: number) => packedGraph.vertices.p[v],
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Returns the polygon points for a grid cell given its index
|
|
* @param {number} i - The index of the grid cell
|
|
* @returns {Array} - An array of polygon points for the specified grid cell
|
|
*/
|
|
export const getGridPolygon = (i: number, grid: any) => {
|
|
return grid.cells.v[i].map((v: number) => grid.vertices.p[v]);
|
|
};
|
|
|
|
/**
|
|
* mbostock's poissonDiscSampler implementation
|
|
* Generates points using Poisson-disc sampling within a specified rectangle
|
|
* @param {number} x0 - The minimum x coordinate of the rectangle
|
|
* @param {number} y0 - The minimum y coordinate of the rectangle
|
|
* @param {number} x1 - The maximum x coordinate of the rectangle
|
|
* @param {number} y1 - The maximum y coordinate of the rectangle
|
|
* @param {number} r - The minimum distance between points
|
|
* @param {number} k - The number of attempts before rejection (default is 3)
|
|
* @yields {Array} - An array containing the x and y coordinates of a generated point
|
|
*/
|
|
export function* poissonDiscSampler(
|
|
x0: number,
|
|
y0: number,
|
|
x1: number,
|
|
y1: number,
|
|
r: number,
|
|
k = 3,
|
|
) {
|
|
if (!(x1 >= x0) || !(y1 >= y0) || !(r > 0)) throw new Error();
|
|
|
|
const width = x1 - x0;
|
|
const height = y1 - y0;
|
|
const r2 = r * r;
|
|
const r2_3 = 3 * r2;
|
|
const cellSize = r * Math.SQRT1_2;
|
|
const gridWidth = Math.ceil(width / cellSize);
|
|
const gridHeight = Math.ceil(height / cellSize);
|
|
const grid = new Array(gridWidth * gridHeight);
|
|
const queue: [number, number][] = [];
|
|
|
|
function far(x: number, y: number) {
|
|
const i = (x / cellSize) | 0;
|
|
const j = (y / cellSize) | 0;
|
|
const i0 = Math.max(i - 2, 0);
|
|
const j0 = Math.max(j - 2, 0);
|
|
const i1 = Math.min(i + 3, gridWidth);
|
|
const j1 = Math.min(j + 3, gridHeight);
|
|
for (let j = j0; j < j1; ++j) {
|
|
const o = j * gridWidth;
|
|
for (let i = i0; i < i1; ++i) {
|
|
const s = grid[o + i];
|
|
if (s) {
|
|
const dx = s[0] - x;
|
|
const dy = s[1] - y;
|
|
if (dx * dx + dy * dy < r2) return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function sample(x: number, y: number): [number, number] {
|
|
const point: [number, number] = [x, y];
|
|
grid[gridWidth * ((y / cellSize) | 0) + ((x / cellSize) | 0)] = point;
|
|
queue.push(point);
|
|
return [x + x0, y + y0];
|
|
}
|
|
|
|
yield sample(width / 2, height / 2);
|
|
|
|
pick: while (queue.length) {
|
|
const i = (Math.random() * queue.length) | 0;
|
|
const parent = queue[i];
|
|
|
|
for (let j = 0; j < k; ++j) {
|
|
const a = 2 * Math.PI * Math.random();
|
|
const r = Math.sqrt(Math.random() * r2_3 + r2);
|
|
const x = parent[0] + r * Math.cos(a);
|
|
const y = parent[1] + r * Math.sin(a);
|
|
if (0 <= x && x < width && 0 <= y && y < height && far(x, y)) {
|
|
yield sample(x, y);
|
|
continue pick;
|
|
}
|
|
}
|
|
|
|
const r = queue.pop();
|
|
if (r !== undefined && i < queue.length) queue[i] = r;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a packed cell is land based on its height
|
|
* @param {number} i - The index of the packed cell
|
|
* @returns {boolean} - True if the cell is land, false otherwise
|
|
*/
|
|
export const isLand = (i: number, packedGraph: PackedGraph) => {
|
|
return packedGraph.cells.h[i] >= 20;
|
|
};
|
|
|
|
/**
|
|
* Checks if a packed cell is water based on its height
|
|
* @param {number} i - The index of the packed cell
|
|
* @returns {boolean} - True if the cell is water, false otherwise
|
|
*/
|
|
export const isWater = (i: number, packedGraph: PackedGraph) => {
|
|
return packedGraph.cells.h[i] < 20;
|
|
};
|
|
|
|
// draw raster heightmap preview (not used in main generation)
|
|
/**
|
|
* Draws a raster heightmap preview based on given heights and rendering options
|
|
* @param {Object} options - The options for drawing the heightmap
|
|
* @param {Array} options.heights - The array of height values
|
|
* @param {number} options.width - The width of the heightmap
|
|
* @param {number} options.height - The height of the heightmap
|
|
* @param {Function} options.scheme - The color scheme function for rendering heights
|
|
* @param {boolean} options.renderOcean - Whether to render ocean heights
|
|
* @returns {string} - A data URL representing the drawn heightmap image
|
|
*/
|
|
export const drawHeights = ({
|
|
heights,
|
|
width,
|
|
height,
|
|
scheme,
|
|
renderOcean,
|
|
}: {
|
|
heights: number[];
|
|
width: number;
|
|
height: number;
|
|
scheme: (value: number) => string;
|
|
renderOcean: boolean;
|
|
}) => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d")!;
|
|
const imageData = ctx.createImageData(width, height);
|
|
|
|
const getHeight = (height: number) =>
|
|
height < 20 ? (renderOcean ? height : 0) : height;
|
|
|
|
for (let i = 0; i < heights.length; i++) {
|
|
const colorScheme = scheme(1 - getHeight(heights[i]) / 100);
|
|
const { r, g, b } = color(colorScheme)?.rgb() ?? { r: 0, g: 0, b: 0 };
|
|
|
|
const n = i * 4;
|
|
imageData.data[n] = r;
|
|
imageData.data[n + 1] = g;
|
|
imageData.data[n + 2] = b;
|
|
imageData.data[n + 3] = 255;
|
|
}
|
|
|
|
ctx.putImageData(imageData, 0, 0);
|
|
return canvas.toDataURL("image/png");
|
|
};
|
|
|
|
declare global {
|
|
var TIME: boolean;
|
|
interface Window {
|
|
shouldRegenerateGrid: typeof shouldRegenerateGrid;
|
|
generateGrid: typeof generateGrid;
|
|
findCell: typeof findClosestCell;
|
|
findGridCell: typeof findGridCell;
|
|
findGridAll: typeof findGridAll;
|
|
calculateVoronoi: typeof calculateVoronoi;
|
|
findAll: typeof findAllCellsInRadius;
|
|
getPackPolygon: typeof getPackPolygon;
|
|
getGridPolygon: typeof getGridPolygon;
|
|
poissonDiscSampler: typeof poissonDiscSampler;
|
|
isLand: typeof isLand;
|
|
isWater: typeof isWater;
|
|
findAllInQuadtree: typeof findAllInQuadtree;
|
|
drawHeights: typeof drawHeights;
|
|
}
|
|
}
|