mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-02-04 17:41:23 +01:00
582 lines
No EOL
19 KiB
TypeScript
582 lines
No EOL
19 KiB
TypeScript
import Alea from "alea";
|
|
import { range as d3Range, leastIndex, mean } from "d3";
|
|
import { createTypedArray, byId,findGridCell,getNumberInRange,lim,minmax,P,rand } from "../utils";
|
|
|
|
declare global {
|
|
interface Window {
|
|
HeightmapGenerator: HeightmapGenerator;
|
|
}
|
|
var heightmapTemplates: any;
|
|
var TIME: boolean;
|
|
var ERROR: boolean;
|
|
}
|
|
|
|
type Tool = "Hill" | "Pit" | "Range" | "Trough" | "Strait" | "Mask" | "Invert" | "Add" | "Multiply" | "Smooth";
|
|
|
|
class HeightmapGenerator {
|
|
grid: any = null;
|
|
heights: Uint8Array | null = null;
|
|
blobPower: number = 0;
|
|
linePower: number = 0;
|
|
|
|
// TODO: remove after migration to TS and use param in constructor
|
|
get seed() {
|
|
return (window as any).seed;
|
|
}
|
|
get graphWidth() {
|
|
return (window as any).graphWidth;
|
|
}
|
|
get graphHeight() {
|
|
return (window as any).graphHeight;
|
|
}
|
|
|
|
constructor() {
|
|
|
|
}
|
|
|
|
private clearData() {
|
|
this.heights = null;
|
|
this.grid = null;
|
|
};
|
|
|
|
|
|
private getBlobPower(cells: number): number {
|
|
const blobPowerMap: Record<number, number> = {
|
|
1000: 0.93,
|
|
2000: 0.95,
|
|
5000: 0.97,
|
|
10000: 0.98,
|
|
20000: 0.99,
|
|
30000: 0.991,
|
|
40000: 0.993,
|
|
50000: 0.994,
|
|
60000: 0.995,
|
|
70000: 0.9955,
|
|
80000: 0.996,
|
|
90000: 0.9964,
|
|
100000: 0.9973
|
|
};
|
|
return blobPowerMap[cells] || 0.98;
|
|
}
|
|
|
|
private getLinePower(cells: number): number {
|
|
const linePowerMap: Record<number, number> = {
|
|
1000: 0.75,
|
|
2000: 0.77,
|
|
5000: 0.79,
|
|
10000: 0.81,
|
|
20000: 0.82,
|
|
30000: 0.83,
|
|
40000: 0.84,
|
|
50000: 0.86,
|
|
60000: 0.87,
|
|
70000: 0.88,
|
|
80000: 0.91,
|
|
90000: 0.92,
|
|
100000: 0.93
|
|
};
|
|
|
|
return linePowerMap[cells] || 0.81;
|
|
}
|
|
|
|
private getPointInRange(range: string, length: number): number | undefined {
|
|
if (typeof range !== "string") {
|
|
window.ERROR && console.error("Range should be a string");
|
|
return;
|
|
}
|
|
|
|
const min = parseInt(range.split("-")[0]) / 100 || 0;
|
|
const max = parseInt(range.split("-")[1]) / 100 || min;
|
|
return rand(min * length, max * length);
|
|
}
|
|
|
|
setGraph(graph: any) {
|
|
const {cellsDesired, cells, points} = graph;
|
|
this.heights = cells.h ? Uint8Array.from(cells.h) : createTypedArray({maxValue: 100, length: points.length}) as Uint8Array;
|
|
this.blobPower = this.getBlobPower(cellsDesired);
|
|
this.linePower = this.getLinePower(cellsDesired);
|
|
this.grid = graph;
|
|
};
|
|
|
|
addHill(count: string, height: string, rangeX: string, rangeY: string): void {
|
|
const addOneHill = () => {
|
|
if(!this.heights || !this.grid) return;
|
|
const change = new Uint8Array(this.heights.length);
|
|
let limit = 0;
|
|
let start: number;
|
|
let h = lim(getNumberInRange(height));
|
|
|
|
do {
|
|
const x = this.getPointInRange(rangeX, this.graphWidth);
|
|
const y = this.getPointInRange(rangeY, this.graphHeight);
|
|
if (x === undefined || y === undefined) return;
|
|
start = findGridCell(x, y, this.grid);
|
|
limit++;
|
|
} while (this.heights[start] + h > 90 && limit < 50);
|
|
change[start] = h;
|
|
const queue = [start];
|
|
while (queue.length) {
|
|
const q = queue.shift() as number;
|
|
|
|
for (const c of this.grid.cells.c[q]) {
|
|
if (change[c]) continue;
|
|
change[c] = change[q] ** this.blobPower * (Math.random() * 0.2 + 0.9);
|
|
if (change[c] > 1) queue.push(c);
|
|
}
|
|
}
|
|
|
|
this.heights = this.heights.map((h, i) => lim(h + change[i]));
|
|
}
|
|
|
|
const desiredHillCount = getNumberInRange(count);
|
|
for (let i = 0; i < desiredHillCount; i++) {
|
|
addOneHill();
|
|
}
|
|
};
|
|
|
|
addPit(count: string, height: string, rangeX: string, rangeY: string): void {
|
|
const addOnePit = () => {
|
|
if(!this.heights || !this.grid) return;
|
|
const used = new Uint8Array(this.heights.length);
|
|
let limit = 0;
|
|
let start: number;
|
|
let h = lim(getNumberInRange(height));
|
|
|
|
do {
|
|
const x = this.getPointInRange(rangeX, this.graphWidth);
|
|
const y = this.getPointInRange(rangeY, this.graphHeight);
|
|
if (x === undefined || y === undefined) return;
|
|
start = findGridCell(x, y, this.grid);
|
|
limit++;
|
|
} while (this.heights[start] < 20 && limit < 50);
|
|
|
|
const queue = [start];
|
|
while (queue.length) {
|
|
const q = queue.shift() as number;
|
|
h = h ** this.blobPower * (Math.random() * 0.2 + 0.9);
|
|
if (h < 1) return;
|
|
|
|
this.grid.cells.c[q].forEach((c: number) => {
|
|
if (used[c] || this.heights === null) return;
|
|
this.heights[c] = lim(this.heights[c] - h * (Math.random() * 0.2 + 0.9));
|
|
used[c] = 1;
|
|
queue.push(c);
|
|
});
|
|
}
|
|
}
|
|
|
|
const desiredPitCount = getNumberInRange(count);
|
|
for (let i = 0; i < desiredPitCount; i++) {
|
|
addOnePit();
|
|
}
|
|
};
|
|
|
|
addRange(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void {
|
|
if(!this.heights || !this.grid) return;
|
|
|
|
const addOneRange = () => {
|
|
if(!this.heights || !this.grid) return;
|
|
|
|
// get main ridge
|
|
const getRange = (cur: number, end: number) => {
|
|
const range = [cur];
|
|
const p = this.grid.points;
|
|
used[cur] = 1;
|
|
|
|
while (cur !== end) {
|
|
let min = Infinity;
|
|
this.grid.cells.c[cur].forEach((e: number) => {
|
|
if (used[e]) return;
|
|
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
|
|
if (Math.random() > 0.85) diff = diff / 2;
|
|
if (diff < min) {
|
|
min = diff;
|
|
cur = e;
|
|
}
|
|
});
|
|
if (min === Infinity) return range;
|
|
range.push(cur);
|
|
used[cur] = 1;
|
|
}
|
|
|
|
return range;
|
|
}
|
|
|
|
const used = new Uint8Array(this.heights.length);
|
|
let h = lim(getNumberInRange(height));
|
|
|
|
if (rangeX && rangeY) {
|
|
// find start and end points
|
|
const startX = this.getPointInRange(rangeX, this.graphWidth) as number;
|
|
const startY = this.getPointInRange(rangeY, this.graphHeight) as number;
|
|
|
|
let dist = 0;
|
|
let limit = 0;
|
|
let endY;
|
|
let endX;
|
|
|
|
do {
|
|
endX = Math.random() * this.graphWidth * 0.8 + this.graphWidth * 0.1;
|
|
endY = Math.random() * this.graphHeight * 0.7 + this.graphHeight * 0.15;
|
|
dist = Math.abs(endY - startY) + Math.abs(endX - startX);
|
|
limit++;
|
|
} while ((dist < this.graphWidth / 8 || dist > this.graphWidth / 3) && limit < 50);
|
|
|
|
startCellId = findGridCell(startX, startY, this.grid);
|
|
endCellId = findGridCell(endX, endY, this.grid);
|
|
}
|
|
|
|
let range = getRange(startCellId as number, endCellId as number);
|
|
|
|
|
|
// add height to ridge and cells around
|
|
let queue = range.slice();
|
|
let i = 0;
|
|
while (queue.length) {
|
|
const frontier = queue.slice();
|
|
(queue = []), i++;
|
|
frontier.forEach((i: number) => {
|
|
if(!this.heights) return;
|
|
this.heights[i] = lim(this.heights[i] + h * (Math.random() * 0.3 + 0.85));
|
|
});
|
|
h = h ** this.linePower - 1;
|
|
if (h < 2) break;
|
|
frontier.forEach((f: number) => {
|
|
this.grid.cells.c[f].forEach((i: number) => {
|
|
if (!used[i]) {
|
|
queue.push(i);
|
|
used[i] = 1;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// generate prominences
|
|
range.forEach((cur: number, d: number) => {
|
|
if (d % 6 !== 0) return;
|
|
for (const _l of d3Range(i)) {
|
|
const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]);
|
|
if(index === undefined) continue;
|
|
const min = this.grid.cells.c[cur][index]; // downhill cell
|
|
this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3;
|
|
cur = min;
|
|
}
|
|
});
|
|
}
|
|
|
|
const desiredRangeCount = getNumberInRange(count);
|
|
for (let i = 0; i < desiredRangeCount; i++) {
|
|
addOneRange();
|
|
}
|
|
};
|
|
|
|
addTrough(count: string, height: string, rangeX: string, rangeY: string, startCellId?: number, endCellId?: number): void {
|
|
const addOneTrough = () => {
|
|
if(!this.heights || !this.grid) return;
|
|
|
|
// get main ridge
|
|
const getRange = (cur: number, end: number) => {
|
|
const range = [cur];
|
|
const p = this.grid.points;
|
|
used[cur] = 1;
|
|
|
|
while (cur !== end) {
|
|
let min = Infinity;
|
|
this.grid.cells.c[cur].forEach((e: number) => {
|
|
if (used[e]) return;
|
|
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
|
|
if (Math.random() > 0.8) diff = diff / 2;
|
|
if (diff < min) {
|
|
min = diff;
|
|
cur = e;
|
|
}
|
|
});
|
|
if (min === Infinity) return range;
|
|
range.push(cur);
|
|
used[cur] = 1;
|
|
}
|
|
|
|
return range;
|
|
}
|
|
|
|
const used = new Uint8Array(this.heights.length);
|
|
let h = lim(getNumberInRange(height));
|
|
|
|
if (rangeX && rangeY) {
|
|
// find start and end points
|
|
let limit = 0;
|
|
let startX: number;
|
|
let startY: number;
|
|
let dist = 0;
|
|
let endX: number;
|
|
let endY: number;
|
|
do {
|
|
startX = this.getPointInRange(rangeX, this.graphWidth) as number;
|
|
startY = this.getPointInRange(rangeY, this.graphHeight) as number;
|
|
startCellId = findGridCell(startX, startY, this.grid);
|
|
limit++;
|
|
} while (this.heights[startCellId] < 20 && limit < 50);
|
|
|
|
limit = 0;
|
|
do {
|
|
endX = Math.random() * this.graphWidth * 0.8 + this.graphWidth * 0.1;
|
|
endY = Math.random() * this.graphHeight * 0.7 + this.graphHeight * 0.15;
|
|
dist = Math.abs(endY - startY) + Math.abs(endX - startX);
|
|
limit++;
|
|
} while ((dist < this.graphWidth / 8 || dist > this.graphWidth / 2) && limit < 50);
|
|
|
|
endCellId = findGridCell(endX, endY, this.grid);
|
|
}
|
|
|
|
let range = getRange(startCellId as number, endCellId as number);
|
|
|
|
|
|
// add height to ridge and cells around
|
|
let queue = range.slice(),
|
|
i = 0;
|
|
while (queue.length) {
|
|
const frontier = queue.slice();
|
|
(queue = []), i++;
|
|
frontier.forEach((i: number) => {
|
|
this.heights![i] = lim(this.heights![i] - h * (Math.random() * 0.3 + 0.85));
|
|
});
|
|
h = h ** this.linePower - 1;
|
|
if (h < 2) break;
|
|
frontier.forEach((f: number) => {
|
|
this.grid.cells.c[f].forEach((i: number) => {
|
|
if (!used[i]) {
|
|
queue.push(i);
|
|
used[i] = 1;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// generate prominences
|
|
range.forEach((cur: number, d: number) => {
|
|
if (d % 6 !== 0) return;
|
|
for (const _l of d3Range(i)) {
|
|
const index = leastIndex(this.grid.cells.c[cur], (a: number, b: number) => this.heights![a] - this.heights![b]);
|
|
if(index === undefined) continue;
|
|
const min = this.grid.cells.c[cur][index]; // downhill cell
|
|
//debug.append("circle").attr("cx", p[min][0]).attr("cy", p[min][1]).attr("r", 1);
|
|
this.heights![min] = (this.heights![cur] * 2 + this.heights![min]) / 3;
|
|
cur = min;
|
|
}
|
|
});
|
|
}
|
|
|
|
const desiredTroughCount = getNumberInRange(count);
|
|
for(let i = 0; i < desiredTroughCount; i++) {
|
|
addOneTrough();
|
|
}
|
|
};
|
|
|
|
addStrait(width: string, direction = "vertical"): void {
|
|
if(!this.heights || !this.grid) return;
|
|
const desiredWidth = Math.min(getNumberInRange(width), this.grid.cellsX / 3);
|
|
if (desiredWidth < 1 && P(desiredWidth)) return;
|
|
const used = new Uint8Array(this.heights.length);
|
|
const vert = direction === "vertical";
|
|
const startX = vert ? Math.floor(Math.random() * this.graphWidth * 0.4 + this.graphWidth * 0.3) : 5;
|
|
const startY = vert ? 5 : Math.floor(Math.random() * this.graphHeight * 0.4 + this.graphHeight * 0.3);
|
|
const endX = vert
|
|
? Math.floor(this.graphWidth - startX - this.graphWidth * 0.1 + Math.random() * this.graphWidth * 0.2)
|
|
: this.graphWidth - 5;
|
|
const endY = vert
|
|
? this.graphHeight - 5
|
|
: Math.floor(this.graphHeight - startY - this.graphHeight * 0.1 + Math.random() * this.graphHeight * 0.2);
|
|
|
|
const start = findGridCell(startX, startY, this.grid);
|
|
const end = findGridCell(endX, endY, this.grid);
|
|
|
|
const getRange = (cur: number, end: number) => {
|
|
const range = [];
|
|
const p = this.grid.points;
|
|
|
|
while (cur !== end) {
|
|
let min = Infinity;
|
|
this.grid.cells.c[cur].forEach((e: number) => {
|
|
let diff = (p[end][0] - p[e][0]) ** 2 + (p[end][1] - p[e][1]) ** 2;
|
|
if (Math.random() > 0.8) diff = diff / 2;
|
|
if (diff < min) {
|
|
min = diff;
|
|
cur = e;
|
|
}
|
|
});
|
|
range.push(cur);
|
|
}
|
|
|
|
return range;
|
|
}
|
|
let range = getRange(start, end);
|
|
const query: number[] = [];
|
|
|
|
|
|
const step = 0.1 / desiredWidth;
|
|
|
|
for(let i = 0; i < desiredWidth; i++) {
|
|
const exp = 0.9 - step * desiredWidth;
|
|
range.forEach((r: number) => {
|
|
this.grid.cells.c[r].forEach((e: number) => {
|
|
if (used[e]) return;
|
|
used[e] = 1;
|
|
query.push(e);
|
|
this.heights![e] **= exp;
|
|
if (this.heights![e] > 100) this.heights![e] = 5;
|
|
});
|
|
});
|
|
range = query.slice();
|
|
}
|
|
};
|
|
|
|
modify(range: string, add: number, mult: number, power?: number): void {
|
|
if(!this.heights) return;
|
|
const min = range === "land" ? 20 : range === "all" ? 0 : +range.split("-")[0];
|
|
const max = range === "land" || range === "all" ? 100 : +range.split("-")[1];
|
|
const isLand = min === 20;
|
|
|
|
this.heights = this.heights.map(h => {
|
|
if (h < min || h > max) return h;
|
|
|
|
if (add) h = isLand ? Math.max(h + add, 20) : h + add;
|
|
if (mult !== 1) h = isLand ? (h - 20) * mult + 20 : h * mult;
|
|
if (power) h = isLand ? (h - 20) ** power + 20 : h ** power;
|
|
return lim(h);
|
|
});
|
|
};
|
|
|
|
smooth(fr = 2, add = 0): void {
|
|
if(!this.heights || !this.grid) return;
|
|
this.heights = this.heights.map((h, i) => {
|
|
const a = [h];
|
|
this.grid.cells.c[i].forEach((c: number) => a.push(this.heights![c]));
|
|
if (fr === 1) return (mean(a) as number) + add;
|
|
return lim((h * (fr - 1) + (mean(a) as number) + add) / fr);
|
|
});
|
|
};
|
|
|
|
mask(power = 1): void {
|
|
if(!this.heights || !this.grid) return;
|
|
const fr = power ? Math.abs(power) : 1;
|
|
|
|
this.heights = this.heights.map((h, i) => {
|
|
const [x, y] = this.grid.points[i];
|
|
const nx = (2 * x) / this.graphWidth - 1; // [-1, 1], 0 is center
|
|
const ny = (2 * y) / this.graphHeight - 1; // [-1, 1], 0 is center
|
|
let distance = (1 - nx ** 2) * (1 - ny ** 2); // 1 is center, 0 is edge
|
|
if (power < 0) distance = 1 - distance; // inverted, 0 is center, 1 is edge
|
|
const masked = h * distance;
|
|
return lim((h * (fr - 1) + masked) / fr);
|
|
});
|
|
};
|
|
|
|
invert(count: number, axes: string): void {
|
|
if (!P(count) || !this.heights || !this.grid) return;
|
|
|
|
const invertX = axes !== "y";
|
|
const invertY = axes !== "x";
|
|
const {cellsX, cellsY} = this.grid;
|
|
|
|
const inverted = this.heights.map((_h: number, i: number) => {
|
|
if(!this.heights) return 0;
|
|
const x = i % cellsX;
|
|
const y = Math.floor(i / cellsX);
|
|
|
|
const nx = invertX ? cellsX - x - 1 : x;
|
|
const ny = invertY ? cellsY - y - 1 : y;
|
|
const invertedI = nx + ny * cellsX;
|
|
return this.heights[invertedI];
|
|
});
|
|
|
|
this.heights = inverted;
|
|
};
|
|
|
|
addStep(tool: Tool, a2: string, a3: string, a4: string, a5: string): void {
|
|
if (tool === "Hill") return this.addHill(a2, a3, a4, a5);
|
|
if (tool === "Pit") return this.addPit(a2, a3, a4, a5);
|
|
if (tool === "Range") return this.addRange(a2, a3, a4, a5);
|
|
if (tool === "Trough") return this.addTrough(a2, a3, a4, a5);
|
|
if (tool === "Strait") return this.addStrait(a2, a3);
|
|
if (tool === "Mask") return this.mask(+a2);
|
|
if (tool === "Invert") return this.invert(+a2, a3);
|
|
if (tool === "Add") return this.modify(a3, +a2, 1);
|
|
if (tool === "Multiply") return this.modify(a3, 0, +a2);
|
|
if (tool === "Smooth") return this.smooth(+a2);
|
|
}
|
|
|
|
async generate(graph: any): Promise<Uint8Array> {
|
|
TIME && console.time("defineHeightmap");
|
|
const id = (byId("templateInput")! as HTMLInputElement).value;
|
|
|
|
Math.random = Alea(this.seed);
|
|
const isTemplate = id in heightmapTemplates;
|
|
|
|
const heights = isTemplate ? this.fromTemplate(graph, id) : await this.fromPrecreated(graph, id);
|
|
TIME && console.timeEnd("defineHeightmap");
|
|
|
|
this.clearData();
|
|
return heights as Uint8Array;
|
|
}
|
|
|
|
fromTemplate(graph: any, id: string): Uint8Array | null {
|
|
const templateString = heightmapTemplates[id]?.template || "";
|
|
const steps = templateString.split("\n");
|
|
|
|
if (!steps.length) throw new Error(`Heightmap template: no steps. Template: ${id}. Steps: ${steps}`);
|
|
this.setGraph(graph);
|
|
|
|
for (const step of steps) {
|
|
const elements = step.trim().split(" ");
|
|
if (elements.length < 2) throw new Error(`Heightmap template: steps < 2. Template: ${id}. Step: ${elements}`);
|
|
this.addStep(...elements as [Tool, string, string, string, string]);
|
|
}
|
|
|
|
return this.heights;
|
|
};
|
|
|
|
private getHeightsFromImageData(imageData: Uint8ClampedArray): void {
|
|
if(!this.heights) return;
|
|
for (let i = 0; i < this.heights.length; i++) {
|
|
const lightness = imageData[i * 4] / 255;
|
|
const powered = lightness < 0.2 ? lightness : 0.2 + (lightness - 0.2) ** 0.8;
|
|
this.heights[i] = minmax(Math.floor(powered * 100), 0, 100);
|
|
}
|
|
}
|
|
|
|
fromPrecreated(graph: any, id: string): Promise<Uint8Array> {
|
|
return new Promise(resolve => {
|
|
// create canvas where 1px corresponts to a cell
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
|
|
const {cellsX, cellsY} = graph;
|
|
canvas.width = cellsX;
|
|
canvas.height = cellsY;
|
|
|
|
// load heightmap into image and render to canvas
|
|
const img = new Image();
|
|
img.src = `./heightmaps/${id}.png`;
|
|
img.onload = () => {
|
|
if(!ctx) {
|
|
throw new Error("Could not get canvas context");
|
|
}
|
|
if(!this.heights) {
|
|
throw new Error("Heights array is not initialized");
|
|
}
|
|
ctx.drawImage(img, 0, 0, cellsX, cellsY);
|
|
const imageData = ctx.getImageData(0, 0, cellsX, cellsY);
|
|
this.setGraph(graph);
|
|
this.getHeightsFromImageData(imageData.data);
|
|
canvas.remove();
|
|
img.remove();
|
|
resolve(this.heights);
|
|
};
|
|
});
|
|
};
|
|
|
|
getHeights() {
|
|
return this.heights;
|
|
}
|
|
}
|
|
|
|
window.HeightmapGenerator = new HeightmapGenerator(); |