mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-02-04 17:41:23 +01:00
refactor: migrate routes
This commit is contained in:
parent
363c82ee30
commit
edb369b4ae
7 changed files with 790 additions and 679 deletions
|
|
@ -1,677 +0,0 @@
|
||||||
const ROUTES_SHARP_ANGLE = 135;
|
|
||||||
const ROUTES_VERY_SHARP_ANGLE = 115;
|
|
||||||
|
|
||||||
const MIN_PASSABLE_SEA_TEMP = -4;
|
|
||||||
const ROUTE_TYPE_MODIFIERS = {
|
|
||||||
"-1": 1, // coastline
|
|
||||||
"-2": 1.8, // sea
|
|
||||||
"-3": 4, // open sea
|
|
||||||
"-4": 6, // ocean
|
|
||||||
default: 8 // far ocean
|
|
||||||
};
|
|
||||||
|
|
||||||
window.Routes = (function () {
|
|
||||||
function generate(lockedRoutes = []) {
|
|
||||||
const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs);
|
|
||||||
|
|
||||||
const connections = new Map();
|
|
||||||
lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2])));
|
|
||||||
|
|
||||||
const mainRoads = generateMainRoads();
|
|
||||||
const trails = generateTrails();
|
|
||||||
const seaRoutes = generateSeaRoutes();
|
|
||||||
|
|
||||||
pack.routes = createRoutesData(lockedRoutes);
|
|
||||||
pack.cells.routes = buildLinks(pack.routes);
|
|
||||||
|
|
||||||
function sortBurgsByFeature(burgs) {
|
|
||||||
const burgsByFeature = {};
|
|
||||||
const capitalsByFeature = {};
|
|
||||||
const portsByFeature = {};
|
|
||||||
|
|
||||||
const addBurg = (collection, feature, burg) => {
|
|
||||||
if (!collection[feature]) collection[feature] = [];
|
|
||||||
collection[feature].push(burg);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const burg of burgs) {
|
|
||||||
if (burg.i && !burg.removed) {
|
|
||||||
const {feature, capital, port} = burg;
|
|
||||||
addBurg(burgsByFeature, feature, burg);
|
|
||||||
if (capital) addBurg(capitalsByFeature, feature, burg);
|
|
||||||
if (port) addBurg(portsByFeature, port, burg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {burgsByFeature, capitalsByFeature, portsByFeature};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMainRoads() {
|
|
||||||
TIME && console.time("generateMainRoads");
|
|
||||||
const mainRoads = [];
|
|
||||||
|
|
||||||
for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) {
|
|
||||||
const points = featureCapitals.map(burg => [burg.x, burg.y]);
|
|
||||||
const urquhartEdges = calculateUrquhartEdges(points);
|
|
||||||
urquhartEdges.forEach(([fromId, toId]) => {
|
|
||||||
const start = featureCapitals[fromId].cell;
|
|
||||||
const exit = featureCapitals[toId].cell;
|
|
||||||
|
|
||||||
const segments = findPathSegments({isWater: false, connections, start, exit});
|
|
||||||
for (const segment of segments) {
|
|
||||||
addConnections(segment);
|
|
||||||
mainRoads.push({feature: Number(key), cells: segment});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
TIME && console.timeEnd("generateMainRoads");
|
|
||||||
return mainRoads;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateTrails() {
|
|
||||||
TIME && console.time("generateTrails");
|
|
||||||
const trails = [];
|
|
||||||
|
|
||||||
for (const [key, featureBurgs] of Object.entries(burgsByFeature)) {
|
|
||||||
const points = featureBurgs.map(burg => [burg.x, burg.y]);
|
|
||||||
const urquhartEdges = calculateUrquhartEdges(points);
|
|
||||||
urquhartEdges.forEach(([fromId, toId]) => {
|
|
||||||
const start = featureBurgs[fromId].cell;
|
|
||||||
const exit = featureBurgs[toId].cell;
|
|
||||||
|
|
||||||
const segments = findPathSegments({isWater: false, connections, start, exit});
|
|
||||||
for (const segment of segments) {
|
|
||||||
addConnections(segment);
|
|
||||||
trails.push({feature: Number(key), cells: segment});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
TIME && console.timeEnd("generateTrails");
|
|
||||||
return trails;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSeaRoutes() {
|
|
||||||
TIME && console.time("generateSeaRoutes");
|
|
||||||
const seaRoutes = [];
|
|
||||||
|
|
||||||
for (const [featureId, featurePorts] of Object.entries(portsByFeature)) {
|
|
||||||
const points = featurePorts.map(burg => [burg.x, burg.y]);
|
|
||||||
const urquhartEdges = calculateUrquhartEdges(points);
|
|
||||||
|
|
||||||
urquhartEdges.forEach(([fromId, toId]) => {
|
|
||||||
const start = featurePorts[fromId].cell;
|
|
||||||
const exit = featurePorts[toId].cell;
|
|
||||||
const segments = findPathSegments({isWater: true, connections, start, exit});
|
|
||||||
for (const segment of segments) {
|
|
||||||
addConnections(segment);
|
|
||||||
seaRoutes.push({feature: Number(featureId), cells: segment});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
TIME && console.timeEnd("generateSeaRoutes");
|
|
||||||
return seaRoutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addConnections(segment) {
|
|
||||||
for (let i = 0; i < segment.length; i++) {
|
|
||||||
const cellId = segment[i];
|
|
||||||
const nextCellId = segment[i + 1];
|
|
||||||
if (nextCellId) {
|
|
||||||
connections.set(`${cellId}-${nextCellId}`, true);
|
|
||||||
connections.set(`${nextCellId}-${cellId}`, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findPathSegments({isWater, connections, start, exit}) {
|
|
||||||
const getCost = createCostEvaluator({isWater, connections});
|
|
||||||
const pathCells = findPath(start, current => current === exit, getCost);
|
|
||||||
if (!pathCells) return [];
|
|
||||||
const segments = getRouteSegments(pathCells, connections);
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRoutesData(routes) {
|
|
||||||
const pointsArray = preparePointsArray();
|
|
||||||
|
|
||||||
for (const {feature, cells, merged} of mergeRoutes(mainRoads)) {
|
|
||||||
if (merged) continue;
|
|
||||||
const points = getPoints("roads", cells, pointsArray);
|
|
||||||
routes.push({i: routes.length, group: "roads", feature, points});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const {feature, cells, merged} of mergeRoutes(trails)) {
|
|
||||||
if (merged) continue;
|
|
||||||
const points = getPoints("trails", cells, pointsArray);
|
|
||||||
routes.push({i: routes.length, group: "trails", feature, points});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const {feature, cells, merged} of mergeRoutes(seaRoutes)) {
|
|
||||||
if (merged) continue;
|
|
||||||
const points = getPoints("searoutes", cells, pointsArray);
|
|
||||||
routes.push({i: routes.length, group: "searoutes", feature, points});
|
|
||||||
}
|
|
||||||
|
|
||||||
return routes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// merge routes so that the last cell of one route is the first cell of the next route
|
|
||||||
function mergeRoutes(routes) {
|
|
||||||
let routesMerged = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < routes.length; i++) {
|
|
||||||
const thisRoute = routes[i];
|
|
||||||
if (thisRoute.merged) continue;
|
|
||||||
|
|
||||||
for (let j = i + 1; j < routes.length; j++) {
|
|
||||||
const nextRoute = routes[j];
|
|
||||||
if (nextRoute.merged) continue;
|
|
||||||
|
|
||||||
if (nextRoute.cells.at(0) === thisRoute.cells.at(-1)) {
|
|
||||||
routesMerged++;
|
|
||||||
thisRoute.cells = thisRoute.cells.concat(nextRoute.cells.slice(1));
|
|
||||||
nextRoute.merged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return routesMerged > 1 ? mergeRoutes(routes) : routes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCostEvaluator({isWater, connections}) {
|
|
||||||
return isWater ? getWaterPathCost : getLandPathCost;
|
|
||||||
|
|
||||||
function getLandPathCost(current, next) {
|
|
||||||
if (pack.cells.h[next] < 20) return Infinity; // ignore water cells
|
|
||||||
|
|
||||||
const habitability = biomesData.habitability[pack.cells.biome[next]];
|
|
||||||
if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier)
|
|
||||||
|
|
||||||
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
|
|
||||||
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
|
|
||||||
const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3];
|
|
||||||
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
|
|
||||||
const burgModifier = pack.cells.burg[next] ? 1 : 3;
|
|
||||||
|
|
||||||
const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier;
|
|
||||||
return pathCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWaterPathCost(current, next) {
|
|
||||||
if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells
|
|
||||||
if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP) return Infinity; // ignore too cold cells
|
|
||||||
|
|
||||||
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
|
|
||||||
const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default;
|
|
||||||
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
|
|
||||||
|
|
||||||
const pathCost = distanceCost * typeModifier * connectionModifier;
|
|
||||||
return pathCost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLinks(routes) {
|
|
||||||
const links = {};
|
|
||||||
|
|
||||||
for (const {points, i: routeId} of routes) {
|
|
||||||
const cells = points.map(p => p[2]);
|
|
||||||
|
|
||||||
for (let i = 0; i < cells.length - 1; i++) {
|
|
||||||
const cellId = cells[i];
|
|
||||||
const nextCellId = cells[i + 1];
|
|
||||||
|
|
||||||
if (cellId !== nextCellId) {
|
|
||||||
if (!links[cellId]) links[cellId] = {};
|
|
||||||
links[cellId][nextCellId] = routeId;
|
|
||||||
|
|
||||||
if (!links[nextCellId]) links[nextCellId] = {};
|
|
||||||
links[nextCellId][cellId] = routeId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return links;
|
|
||||||
}
|
|
||||||
|
|
||||||
function preparePointsArray() {
|
|
||||||
const {cells, burgs} = pack;
|
|
||||||
return cells.p.map(([x, y], cellId) => {
|
|
||||||
const burgId = cells.burg[cellId];
|
|
||||||
if (burgId) return [burgs[burgId].x, burgs[burgId].y];
|
|
||||||
return [x, y];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPoints(group, cells, points) {
|
|
||||||
const data = cells.map(cellId => [...points[cellId], cellId]);
|
|
||||||
|
|
||||||
// resolve sharp angles
|
|
||||||
if (group !== "searoutes") {
|
|
||||||
for (let i = 1; i < cells.length - 1; i++) {
|
|
||||||
const cellId = cells[i];
|
|
||||||
if (pack.cells.burg[cellId]) continue;
|
|
||||||
|
|
||||||
const [prevX, prevY] = data[i - 1];
|
|
||||||
const [currX, currY] = data[i];
|
|
||||||
const [nextX, nextY] = data[i + 1];
|
|
||||||
|
|
||||||
const dAx = prevX - currX;
|
|
||||||
const dAy = prevY - currY;
|
|
||||||
const dBx = nextX - currX;
|
|
||||||
const dBy = nextY - currY;
|
|
||||||
const angle = Math.abs((Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / Math.PI);
|
|
||||||
|
|
||||||
if (angle < ROUTES_SHARP_ANGLE) {
|
|
||||||
const middleX = (prevX + nextX) / 2;
|
|
||||||
const middleY = (prevY + nextY) / 2;
|
|
||||||
let newX, newY;
|
|
||||||
|
|
||||||
if (angle < ROUTES_VERY_SHARP_ANGLE) {
|
|
||||||
newX = rn((currX + middleX * 2) / 3, 2);
|
|
||||||
newY = rn((currY + middleY * 2) / 3, 2);
|
|
||||||
} else {
|
|
||||||
newX = rn((currX + middleX) / 2, 2);
|
|
||||||
newY = rn((currY + middleY) / 2, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (findCell(newX, newY) === cellId) {
|
|
||||||
data[i] = [newX, newY, cellId];
|
|
||||||
points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data; // [[x, y, cell], [x, y, cell]];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRouteSegments(pathCells, connections) {
|
|
||||||
const segments = [];
|
|
||||||
let segment = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < pathCells.length; i++) {
|
|
||||||
const cellId = pathCells[i];
|
|
||||||
const nextCellId = pathCells[i + 1];
|
|
||||||
const isConnected = connections.has(`${cellId}-${nextCellId}`) || connections.has(`${nextCellId}-${cellId}`);
|
|
||||||
|
|
||||||
if (isConnected) {
|
|
||||||
if (segment.length) {
|
|
||||||
// segment stepped into existing segment
|
|
||||||
segment.push(pathCells[i]);
|
|
||||||
segments.push(segment);
|
|
||||||
segment = [];
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
segment.push(pathCells[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segment.length > 1) segments.push(segment);
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation
|
|
||||||
// this gives us an aproximation of a desired road network, i.e. connections between burgs
|
|
||||||
// code from https://observablehq.com/@mbostock/urquhart-graph
|
|
||||||
function calculateUrquhartEdges(points) {
|
|
||||||
const score = (p0, p1) => dist2(points[p0], points[p1]);
|
|
||||||
|
|
||||||
const {halfedges, triangles} = Delaunator.from(points);
|
|
||||||
const n = triangles.length;
|
|
||||||
|
|
||||||
const removed = new Uint8Array(n);
|
|
||||||
const edges = [];
|
|
||||||
|
|
||||||
for (let e = 0; e < n; e += 3) {
|
|
||||||
const p0 = triangles[e],
|
|
||||||
p1 = triangles[e + 1],
|
|
||||||
p2 = triangles[e + 2];
|
|
||||||
|
|
||||||
const p01 = score(p0, p1),
|
|
||||||
p12 = score(p1, p2),
|
|
||||||
p20 = score(p2, p0);
|
|
||||||
|
|
||||||
removed[
|
|
||||||
p20 > p01 && p20 > p12
|
|
||||||
? Math.max(e + 2, halfedges[e + 2])
|
|
||||||
: p12 > p01 && p12 > p20
|
|
||||||
? Math.max(e + 1, halfedges[e + 1])
|
|
||||||
: Math.max(e, halfedges[e])
|
|
||||||
] = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let e = 0; e < n; ++e) {
|
|
||||||
if (e > halfedges[e] && !removed[e]) {
|
|
||||||
const t0 = triangles[e];
|
|
||||||
const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1];
|
|
||||||
edges.push([t0, t1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return edges;
|
|
||||||
}
|
|
||||||
|
|
||||||
// connect cell with routes system by land
|
|
||||||
function connect(cellId) {
|
|
||||||
const getCost = createCostEvaluator({isWater: false, connections: new Map()});
|
|
||||||
const isExit = c => isLand(c) && isConnected(c);
|
|
||||||
const pathCells = findPath(cellId, isExit, getCost);
|
|
||||||
if (!pathCells) return;
|
|
||||||
|
|
||||||
const pointsArray = preparePointsArray();
|
|
||||||
const points = getPoints("trails", pathCells, pointsArray);
|
|
||||||
const feature = pack.cells.f[cellId];
|
|
||||||
const routeId = getNextId();
|
|
||||||
const newRoute = {i: routeId, group: "trails", feature, points};
|
|
||||||
pack.routes.push(newRoute);
|
|
||||||
|
|
||||||
for (let i = 0; i < pathCells.length; i++) {
|
|
||||||
const currentCell = pathCells[i];
|
|
||||||
const nextCellId = pathCells[i + 1];
|
|
||||||
if (nextCellId) addConnection(currentCell, nextCellId, routeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newRoute;
|
|
||||||
|
|
||||||
function addConnection(from, to, routeId) {
|
|
||||||
const routes = pack.cells.routes;
|
|
||||||
|
|
||||||
if (!routes[from]) routes[from] = {};
|
|
||||||
routes[from][to] = routeId;
|
|
||||||
|
|
||||||
if (!routes[to]) routes[to] = {};
|
|
||||||
routes[to][from] = routeId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// utility functions
|
|
||||||
function isConnected(cellId) {
|
|
||||||
const routes = pack.cells.routes;
|
|
||||||
return routes[cellId] && Object.keys(routes[cellId]).length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function areConnected(from, to) {
|
|
||||||
const routeId = pack.cells.routes[from]?.[to];
|
|
||||||
return routeId !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRoute(from, to) {
|
|
||||||
const routeId = pack.cells.routes[from]?.[to];
|
|
||||||
if (routeId === undefined) return null;
|
|
||||||
|
|
||||||
const route = pack.routes.find(route => route.i === routeId);
|
|
||||||
if (!route) return null;
|
|
||||||
|
|
||||||
return route;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasRoad(cellId) {
|
|
||||||
const connections = pack.cells.routes[cellId];
|
|
||||||
if (!connections) return false;
|
|
||||||
|
|
||||||
return Object.values(connections).some(routeId => {
|
|
||||||
const route = pack.routes.find(route => route.i === routeId);
|
|
||||||
if (!route) return false;
|
|
||||||
return route.group === "roads";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCrossroad(cellId) {
|
|
||||||
const connections = pack.cells.routes[cellId];
|
|
||||||
if (!connections) return false;
|
|
||||||
if (Object.keys(connections).length > 3) return true;
|
|
||||||
const roadConnections = Object.values(connections).filter(routeId => {
|
|
||||||
const route = pack.routes.find(route => route.i === routeId);
|
|
||||||
return route?.group === "roads";
|
|
||||||
});
|
|
||||||
return roadConnections.length > 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectivityRateMap = {
|
|
||||||
roads: 0.2,
|
|
||||||
trails: 0.1,
|
|
||||||
searoutes: 0.2,
|
|
||||||
default: 0.1
|
|
||||||
};
|
|
||||||
|
|
||||||
function getConnectivityRate(cellId) {
|
|
||||||
const connections = pack.cells.routes[cellId];
|
|
||||||
if (!connections) return 0;
|
|
||||||
|
|
||||||
const connectivity = Object.values(connections).reduce((acc, routeId) => {
|
|
||||||
const route = pack.routes.find(route => route.i === routeId);
|
|
||||||
if (!route) return acc;
|
|
||||||
const rate = connectivityRateMap[route.group] || connectivityRateMap.default;
|
|
||||||
return acc + rate;
|
|
||||||
}, 0.8);
|
|
||||||
|
|
||||||
return connectivity;
|
|
||||||
}
|
|
||||||
|
|
||||||
// name generator data
|
|
||||||
const models = {
|
|
||||||
roads: {burg_suffix: 3, prefix_suffix: 6, the_descriptor_prefix_suffix: 2, the_descriptor_burg_suffix: 1},
|
|
||||||
trails: {burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1},
|
|
||||||
searoutes: {burg_suffix: 4, prefix_suffix: 2, the_descriptor_prefix_suffix: 1}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prefixes = [
|
|
||||||
"King",
|
|
||||||
"Queen",
|
|
||||||
"Military",
|
|
||||||
"Old",
|
|
||||||
"New",
|
|
||||||
"Ancient",
|
|
||||||
"Royal",
|
|
||||||
"Imperial",
|
|
||||||
"Great",
|
|
||||||
"Grand",
|
|
||||||
"High",
|
|
||||||
"Silver",
|
|
||||||
"Dragon",
|
|
||||||
"Shadow",
|
|
||||||
"Star",
|
|
||||||
"Mystic",
|
|
||||||
"Whisper",
|
|
||||||
"Eagle",
|
|
||||||
"Golden",
|
|
||||||
"Crystal",
|
|
||||||
"Enchanted",
|
|
||||||
"Frost",
|
|
||||||
"Moon",
|
|
||||||
"Sun",
|
|
||||||
"Thunder",
|
|
||||||
"Phoenix",
|
|
||||||
"Sapphire",
|
|
||||||
"Celestial",
|
|
||||||
"Wandering",
|
|
||||||
"Echo",
|
|
||||||
"Twilight",
|
|
||||||
"Crimson",
|
|
||||||
"Serpent",
|
|
||||||
"Iron",
|
|
||||||
"Forest",
|
|
||||||
"Flower",
|
|
||||||
"Whispering",
|
|
||||||
"Eternal",
|
|
||||||
"Frozen",
|
|
||||||
"Rain",
|
|
||||||
"Luminous",
|
|
||||||
"Stardust",
|
|
||||||
"Arcane",
|
|
||||||
"Glimmering",
|
|
||||||
"Jade",
|
|
||||||
"Ember",
|
|
||||||
"Azure",
|
|
||||||
"Gilded",
|
|
||||||
"Divine",
|
|
||||||
"Shadowed",
|
|
||||||
"Cursed",
|
|
||||||
"Moonlit",
|
|
||||||
"Sable",
|
|
||||||
"Everlasting",
|
|
||||||
"Amber",
|
|
||||||
"Nightshade",
|
|
||||||
"Wraith",
|
|
||||||
"Scarlet",
|
|
||||||
"Platinum",
|
|
||||||
"Whirlwind",
|
|
||||||
"Obsidian",
|
|
||||||
"Ethereal",
|
|
||||||
"Ghost",
|
|
||||||
"Spike",
|
|
||||||
"Dusk",
|
|
||||||
"Raven",
|
|
||||||
"Spectral",
|
|
||||||
"Burning",
|
|
||||||
"Verdant",
|
|
||||||
"Copper",
|
|
||||||
"Velvet",
|
|
||||||
"Falcon",
|
|
||||||
"Enigma",
|
|
||||||
"Glowing",
|
|
||||||
"Silvered",
|
|
||||||
"Molten",
|
|
||||||
"Radiant",
|
|
||||||
"Astral",
|
|
||||||
"Wild",
|
|
||||||
"Flame",
|
|
||||||
"Amethyst",
|
|
||||||
"Aurora",
|
|
||||||
"Shadowy",
|
|
||||||
"Solar",
|
|
||||||
"Lunar",
|
|
||||||
"Whisperwind",
|
|
||||||
"Fading",
|
|
||||||
"Titan",
|
|
||||||
"Dawn",
|
|
||||||
"Crystalline",
|
|
||||||
"Jeweled",
|
|
||||||
"Sylvan",
|
|
||||||
"Twisted",
|
|
||||||
"Ebon",
|
|
||||||
"Thorn",
|
|
||||||
"Cerulean",
|
|
||||||
"Halcyon",
|
|
||||||
"Infernal",
|
|
||||||
"Storm",
|
|
||||||
"Eldritch",
|
|
||||||
"Sapphire",
|
|
||||||
"Crimson",
|
|
||||||
"Tranquil",
|
|
||||||
"Paved"
|
|
||||||
];
|
|
||||||
|
|
||||||
const descriptors = [
|
|
||||||
"Great",
|
|
||||||
"Shrouded",
|
|
||||||
"Sacred",
|
|
||||||
"Fabled",
|
|
||||||
"Frosty",
|
|
||||||
"Winding",
|
|
||||||
"Echoing",
|
|
||||||
"Serpentine",
|
|
||||||
"Breezy",
|
|
||||||
"Misty",
|
|
||||||
"Rustic",
|
|
||||||
"Silent",
|
|
||||||
"Cobbled",
|
|
||||||
"Cracked",
|
|
||||||
"Shaky",
|
|
||||||
"Obscure"
|
|
||||||
];
|
|
||||||
|
|
||||||
const suffixes = {
|
|
||||||
roads: {road: 7, route: 3, way: 2, highway: 1},
|
|
||||||
trails: {trail: 4, path: 1, track: 1, pass: 1},
|
|
||||||
searoutes: {"sea route": 5, lane: 2, passage: 1, seaway: 1}
|
|
||||||
};
|
|
||||||
|
|
||||||
function generateName({group, points}) {
|
|
||||||
if (points.length < 4) return "Unnamed route segment";
|
|
||||||
|
|
||||||
const model = rw(models[group]);
|
|
||||||
const suffix = rw(suffixes[group]);
|
|
||||||
|
|
||||||
const burgName = getBurgName();
|
|
||||||
if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`;
|
|
||||||
if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`;
|
|
||||||
if (model === "the_descriptor_prefix_suffix") return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`;
|
|
||||||
if (model === "the_descriptor_burg_suffix" && burgName) return `The ${ra(descriptors)} ${burgName} ${suffix}`;
|
|
||||||
return "Unnamed route";
|
|
||||||
|
|
||||||
function getBurgName() {
|
|
||||||
const priority = [points.at(-1), points.at(0), points.slice(1, -1).reverse()];
|
|
||||||
for (const [_x, _y, cellId] of priority) {
|
|
||||||
const burgId = pack.cells.burg[cellId];
|
|
||||||
if (burgId) return getAdjective(pack.burgs[burgId].name);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROUTE_CURVES = {
|
|
||||||
roads: d3.curveCatmullRom.alpha(0.1),
|
|
||||||
trails: d3.curveCatmullRom.alpha(0.1),
|
|
||||||
searoutes: d3.curveCatmullRom.alpha(0.5),
|
|
||||||
default: d3.curveCatmullRom.alpha(0.1)
|
|
||||||
};
|
|
||||||
|
|
||||||
function getPath({group, points}) {
|
|
||||||
const lineGen = d3.line();
|
|
||||||
lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default);
|
|
||||||
const path = round(lineGen(points.map(p => [p[0], p[1]])), 1);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLength(routeId) {
|
|
||||||
const path = routes.select("#route" + routeId).node();
|
|
||||||
return path.getTotalLength();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNextId() {
|
|
||||||
return pack.routes.length ? Math.max(...pack.routes.map(r => r.i)) + 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(route) {
|
|
||||||
const routes = pack.cells.routes;
|
|
||||||
|
|
||||||
for (const point of route.points) {
|
|
||||||
const from = point[2];
|
|
||||||
if (!routes[from]) continue;
|
|
||||||
|
|
||||||
for (const [to, routeId] of Object.entries(routes[from])) {
|
|
||||||
if (routeId === route.i) {
|
|
||||||
delete routes[from][to];
|
|
||||||
delete routes[to][from];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pack.routes = pack.routes.filter(r => r.i !== route.i);
|
|
||||||
viewbox.select("#route" + route.i).remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
generate,
|
|
||||||
buildLinks,
|
|
||||||
connect,
|
|
||||||
isConnected,
|
|
||||||
areConnected,
|
|
||||||
getRoute,
|
|
||||||
hasRoad,
|
|
||||||
isCrossroad,
|
|
||||||
getConnectivityRate,
|
|
||||||
generateName,
|
|
||||||
getPath,
|
|
||||||
getLength,
|
|
||||||
getNextId,
|
|
||||||
remove
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
@ -8496,7 +8496,6 @@
|
||||||
<script defer src="modules/ice.js?v=1.111.0"></script>
|
<script defer src="modules/ice.js?v=1.111.0"></script>
|
||||||
<script defer src="modules/states-generator.js?v=1.107.0"></script>
|
<script defer src="modules/states-generator.js?v=1.107.0"></script>
|
||||||
<script defer src="modules/provinces-generator.js?v=1.106.0"></script>
|
<script defer src="modules/provinces-generator.js?v=1.106.0"></script>
|
||||||
<script defer src="modules/routes-generator.js?v=1.106.0"></script>
|
|
||||||
<script defer src="modules/religions-generator.js?v=1.106.0"></script>
|
<script defer src="modules/religions-generator.js?v=1.106.0"></script>
|
||||||
<script defer src="modules/military-generator.js?v=1.107.0"></script>
|
<script defer src="modules/military-generator.js?v=1.107.0"></script>
|
||||||
<script defer src="modules/markers-generator.js?v=1.107.0"></script>
|
<script defer src="modules/markers-generator.js?v=1.107.0"></script>
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,4 @@ import "./river-generator";
|
||||||
import "./burgs-generator";
|
import "./burgs-generator";
|
||||||
import "./biomes";
|
import "./biomes";
|
||||||
import "./cultures-generator";
|
import "./cultures-generator";
|
||||||
|
import "./routes-generator";
|
||||||
|
|
|
||||||
782
src/modules/routes-generator.ts
Normal file
782
src/modules/routes-generator.ts
Normal file
|
|
@ -0,0 +1,782 @@
|
||||||
|
import { curveCatmullRom, line } from "d3";
|
||||||
|
import Delaunator from "delaunator";
|
||||||
|
import {
|
||||||
|
distanceSquared,
|
||||||
|
findClosestCell,
|
||||||
|
findPath,
|
||||||
|
getAdjective,
|
||||||
|
isLand,
|
||||||
|
ra,
|
||||||
|
rn,
|
||||||
|
round,
|
||||||
|
rw,
|
||||||
|
} from "../utils";
|
||||||
|
import type { Burg } from "./burgs-generator";
|
||||||
|
import type { Point } from "./voronoi";
|
||||||
|
|
||||||
|
const ROUTES_SHARP_ANGLE = 135;
|
||||||
|
const ROUTES_VERY_SHARP_ANGLE = 115;
|
||||||
|
|
||||||
|
const MIN_PASSABLE_SEA_TEMP = -4;
|
||||||
|
const ROUTE_TYPE_MODIFIERS: Record<string, number> = {
|
||||||
|
"-1": 1, // coastline
|
||||||
|
"-2": 1.8, // sea
|
||||||
|
"-3": 4, // open sea
|
||||||
|
"-4": 6, // ocean
|
||||||
|
default: 8, // far ocean
|
||||||
|
};
|
||||||
|
|
||||||
|
// name generator data
|
||||||
|
const models: Record<string, Record<string, number>> = {
|
||||||
|
roads: {
|
||||||
|
burg_suffix: 3,
|
||||||
|
prefix_suffix: 6,
|
||||||
|
the_descriptor_prefix_suffix: 2,
|
||||||
|
the_descriptor_burg_suffix: 1,
|
||||||
|
},
|
||||||
|
trails: { burg_suffix: 8, prefix_suffix: 1, the_descriptor_burg_suffix: 1 },
|
||||||
|
searoutes: {
|
||||||
|
burg_suffix: 4,
|
||||||
|
prefix_suffix: 2,
|
||||||
|
the_descriptor_prefix_suffix: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefixes: string[] = [
|
||||||
|
"King",
|
||||||
|
"Queen",
|
||||||
|
"Military",
|
||||||
|
"Old",
|
||||||
|
"New",
|
||||||
|
"Ancient",
|
||||||
|
"Royal",
|
||||||
|
"Imperial",
|
||||||
|
"Great",
|
||||||
|
"Grand",
|
||||||
|
"High",
|
||||||
|
"Silver",
|
||||||
|
"Dragon",
|
||||||
|
"Shadow",
|
||||||
|
"Star",
|
||||||
|
"Mystic",
|
||||||
|
"Whisper",
|
||||||
|
"Eagle",
|
||||||
|
"Golden",
|
||||||
|
"Crystal",
|
||||||
|
"Enchanted",
|
||||||
|
"Frost",
|
||||||
|
"Moon",
|
||||||
|
"Sun",
|
||||||
|
"Thunder",
|
||||||
|
"Phoenix",
|
||||||
|
"Sapphire",
|
||||||
|
"Celestial",
|
||||||
|
"Wandering",
|
||||||
|
"Echo",
|
||||||
|
"Twilight",
|
||||||
|
"Crimson",
|
||||||
|
"Serpent",
|
||||||
|
"Iron",
|
||||||
|
"Forest",
|
||||||
|
"Flower",
|
||||||
|
"Whispering",
|
||||||
|
"Eternal",
|
||||||
|
"Frozen",
|
||||||
|
"Rain",
|
||||||
|
"Luminous",
|
||||||
|
"Stardust",
|
||||||
|
"Arcane",
|
||||||
|
"Glimmering",
|
||||||
|
"Jade",
|
||||||
|
"Ember",
|
||||||
|
"Azure",
|
||||||
|
"Gilded",
|
||||||
|
"Divine",
|
||||||
|
"Shadowed",
|
||||||
|
"Cursed",
|
||||||
|
"Moonlit",
|
||||||
|
"Sable",
|
||||||
|
"Everlasting",
|
||||||
|
"Amber",
|
||||||
|
"Nightshade",
|
||||||
|
"Wraith",
|
||||||
|
"Scarlet",
|
||||||
|
"Platinum",
|
||||||
|
"Whirlwind",
|
||||||
|
"Obsidian",
|
||||||
|
"Ethereal",
|
||||||
|
"Ghost",
|
||||||
|
"Spike",
|
||||||
|
"Dusk",
|
||||||
|
"Raven",
|
||||||
|
"Spectral",
|
||||||
|
"Burning",
|
||||||
|
"Verdant",
|
||||||
|
"Copper",
|
||||||
|
"Velvet",
|
||||||
|
"Falcon",
|
||||||
|
"Enigma",
|
||||||
|
"Glowing",
|
||||||
|
"Silvered",
|
||||||
|
"Molten",
|
||||||
|
"Radiant",
|
||||||
|
"Astral",
|
||||||
|
"Wild",
|
||||||
|
"Flame",
|
||||||
|
"Amethyst",
|
||||||
|
"Aurora",
|
||||||
|
"Shadowy",
|
||||||
|
"Solar",
|
||||||
|
"Lunar",
|
||||||
|
"Whisperwind",
|
||||||
|
"Fading",
|
||||||
|
"Titan",
|
||||||
|
"Dawn",
|
||||||
|
"Crystalline",
|
||||||
|
"Jeweled",
|
||||||
|
"Sylvan",
|
||||||
|
"Twisted",
|
||||||
|
"Ebon",
|
||||||
|
"Thorn",
|
||||||
|
"Cerulean",
|
||||||
|
"Halcyon",
|
||||||
|
"Infernal",
|
||||||
|
"Storm",
|
||||||
|
"Eldritch",
|
||||||
|
"Sapphire",
|
||||||
|
"Crimson",
|
||||||
|
"Tranquil",
|
||||||
|
"Paved",
|
||||||
|
];
|
||||||
|
|
||||||
|
const descriptors = [
|
||||||
|
"Great",
|
||||||
|
"Shrouded",
|
||||||
|
"Sacred",
|
||||||
|
"Fabled",
|
||||||
|
"Frosty",
|
||||||
|
"Winding",
|
||||||
|
"Echoing",
|
||||||
|
"Serpentine",
|
||||||
|
"Breezy",
|
||||||
|
"Misty",
|
||||||
|
"Rustic",
|
||||||
|
"Silent",
|
||||||
|
"Cobbled",
|
||||||
|
"Cracked",
|
||||||
|
"Shaky",
|
||||||
|
"Obscure",
|
||||||
|
];
|
||||||
|
|
||||||
|
const suffixes: Record<string, Record<string, number>> = {
|
||||||
|
roads: { road: 7, route: 3, way: 2, highway: 1 },
|
||||||
|
trails: { trail: 4, path: 1, track: 1, pass: 1 },
|
||||||
|
searoutes: { "sea route": 5, lane: 2, passage: 1, seaway: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
i: number;
|
||||||
|
group: "roads" | "trails" | "searoutes";
|
||||||
|
feature: number;
|
||||||
|
points: number[][];
|
||||||
|
cells?: number[];
|
||||||
|
merged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoutesModule {
|
||||||
|
buildLinks(routes: Route[]): Record<number, Record<number, number>> {
|
||||||
|
const links: Record<number, Record<number, number>> = {};
|
||||||
|
|
||||||
|
for (const { points, i: routeId } of routes) {
|
||||||
|
const cells = points.map((p) => p[2]);
|
||||||
|
|
||||||
|
for (let i = 0; i < cells.length - 1; i++) {
|
||||||
|
const cellId = cells[i];
|
||||||
|
const nextCellId = cells[i + 1];
|
||||||
|
|
||||||
|
if (cellId !== nextCellId) {
|
||||||
|
if (!links[cellId]) links[cellId] = {};
|
||||||
|
links[cellId][nextCellId] = routeId;
|
||||||
|
|
||||||
|
if (!links[nextCellId]) links[nextCellId] = {};
|
||||||
|
links[nextCellId][cellId] = routeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortBurgsByFeature(burgs: Burg[]) {
|
||||||
|
const burgsByFeature: Record<number, Burg[]> = {};
|
||||||
|
const capitalsByFeature: Record<number, Burg[]> = {};
|
||||||
|
const portsByFeature: Record<number, Burg[]> = {};
|
||||||
|
|
||||||
|
const addBurg = (
|
||||||
|
collection: Record<number, Burg[]>,
|
||||||
|
feature: number,
|
||||||
|
burg: Burg,
|
||||||
|
) => {
|
||||||
|
if (!collection[feature]) collection[feature] = [];
|
||||||
|
collection[feature].push(burg);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const burg of burgs) {
|
||||||
|
if (burg.i && !burg.removed) {
|
||||||
|
const { feature, capital, port } = burg;
|
||||||
|
addBurg(burgsByFeature, feature as number, burg);
|
||||||
|
if (capital) addBurg(capitalsByFeature, feature as number, burg);
|
||||||
|
if (port) addBurg(portsByFeature, port as number, burg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { burgsByFeature, capitalsByFeature, portsByFeature };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation
|
||||||
|
// this gives us an aproximation of a desired road network, i.e. connections between burgs
|
||||||
|
// code from https://observablehq.com/@mbostock/urquhart-graph
|
||||||
|
private calculateUrquhartEdges(points: Point[]) {
|
||||||
|
const score = (p0: number, p1: number) =>
|
||||||
|
distanceSquared(points[p0], points[p1]);
|
||||||
|
|
||||||
|
const { halfedges, triangles } = Delaunator.from(points);
|
||||||
|
const n = triangles.length;
|
||||||
|
|
||||||
|
const removed = new Uint8Array(n);
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
for (let e = 0; e < n; e += 3) {
|
||||||
|
const p0 = triangles[e],
|
||||||
|
p1 = triangles[e + 1],
|
||||||
|
p2 = triangles[e + 2];
|
||||||
|
|
||||||
|
const p01 = score(p0, p1),
|
||||||
|
p12 = score(p1, p2),
|
||||||
|
p20 = score(p2, p0);
|
||||||
|
|
||||||
|
removed[
|
||||||
|
p20 > p01 && p20 > p12
|
||||||
|
? Math.max(e + 2, halfedges[e + 2])
|
||||||
|
: p12 > p01 && p12 > p20
|
||||||
|
? Math.max(e + 1, halfedges[e + 1])
|
||||||
|
: Math.max(e, halfedges[e])
|
||||||
|
] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let e = 0; e < n; ++e) {
|
||||||
|
if (e > halfedges[e] && !removed[e]) {
|
||||||
|
const t0 = triangles[e];
|
||||||
|
const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1];
|
||||||
|
edges.push([t0, t1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCostEvaluator({
|
||||||
|
isWater,
|
||||||
|
connections,
|
||||||
|
}: {
|
||||||
|
isWater: boolean;
|
||||||
|
connections: Map<string, boolean>;
|
||||||
|
}) {
|
||||||
|
function getLandPathCost(current: number, next: number) {
|
||||||
|
if (pack.cells.h[next] < 20) return Infinity; // ignore water cells
|
||||||
|
|
||||||
|
const habitability = biomesData.habitability[pack.cells.biome[next]];
|
||||||
|
if (!habitability) return Infinity; // inhabitable cells are not passable (e.g. glacier)
|
||||||
|
|
||||||
|
const distanceCost = distanceSquared(
|
||||||
|
pack.cells.p[current],
|
||||||
|
pack.cells.p[next],
|
||||||
|
);
|
||||||
|
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
|
||||||
|
const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3];
|
||||||
|
const connectionModifier = connections.has(`${current}-${next}`)
|
||||||
|
? 0.5
|
||||||
|
: 1;
|
||||||
|
const burgModifier = pack.cells.burg[next] ? 1 : 3;
|
||||||
|
|
||||||
|
const pathCost =
|
||||||
|
distanceCost *
|
||||||
|
habitabilityModifier *
|
||||||
|
heightModifier *
|
||||||
|
connectionModifier *
|
||||||
|
burgModifier;
|
||||||
|
return pathCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWaterPathCost(current: number, next: number) {
|
||||||
|
if (pack.cells.h[next] >= 20) return Infinity; // ignore land cells
|
||||||
|
if (grid.cells.temp[pack.cells.g[next]] < MIN_PASSABLE_SEA_TEMP)
|
||||||
|
return Infinity; // ignore too cold cells
|
||||||
|
|
||||||
|
const distanceCost = distanceSquared(
|
||||||
|
pack.cells.p[current],
|
||||||
|
pack.cells.p[next],
|
||||||
|
);
|
||||||
|
const typeModifier =
|
||||||
|
ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] ||
|
||||||
|
ROUTE_TYPE_MODIFIERS.default;
|
||||||
|
const connectionModifier = connections.has(`${current}-${next}`)
|
||||||
|
? 0.5
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const pathCost = distanceCost * typeModifier * connectionModifier;
|
||||||
|
return pathCost;
|
||||||
|
}
|
||||||
|
return isWater ? getWaterPathCost : getLandPathCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRouteSegments(
|
||||||
|
pathCells: number[],
|
||||||
|
connections: Map<string, boolean>,
|
||||||
|
) {
|
||||||
|
const segments = [];
|
||||||
|
let segment = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < pathCells.length; i++) {
|
||||||
|
const cellId = pathCells[i];
|
||||||
|
const nextCellId = pathCells[i + 1];
|
||||||
|
const isConnected =
|
||||||
|
connections.has(`${cellId}-${nextCellId}`) ||
|
||||||
|
connections.has(`${nextCellId}-${cellId}`);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
if (segment.length) {
|
||||||
|
// segment stepped into existing segment
|
||||||
|
segment.push(pathCells[i]);
|
||||||
|
segments.push(segment);
|
||||||
|
segment = [];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
segment.push(pathCells[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.length > 1) segments.push(segment);
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findPathSegments({
|
||||||
|
isWater,
|
||||||
|
connections,
|
||||||
|
start,
|
||||||
|
exit,
|
||||||
|
}: {
|
||||||
|
isWater: boolean;
|
||||||
|
connections: Map<string, boolean>;
|
||||||
|
start: number;
|
||||||
|
exit: number;
|
||||||
|
}) {
|
||||||
|
const getCost = this.createCostEvaluator({ isWater, connections });
|
||||||
|
const pathCells = findPath(start, (current) => current === exit, getCost, pack);
|
||||||
|
if (!pathCells) return [];
|
||||||
|
const segments = this.getRouteSegments(pathCells, connections);
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateMainRoads(connections: Map<string, boolean>) {
|
||||||
|
TIME && console.time("generateMainRoads");
|
||||||
|
const { capitalsByFeature } = this.sortBurgsByFeature(pack.burgs);
|
||||||
|
const mainRoads: Route[] = [];
|
||||||
|
|
||||||
|
for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) {
|
||||||
|
const points = featureCapitals.map((burg) => [burg.x, burg.y] as Point);
|
||||||
|
const urquhartEdges = this.calculateUrquhartEdges(points);
|
||||||
|
urquhartEdges.forEach(([fromId, toId]) => {
|
||||||
|
const start = featureCapitals[fromId].cell;
|
||||||
|
const exit = featureCapitals[toId].cell;
|
||||||
|
|
||||||
|
const segments = this.findPathSegments({
|
||||||
|
isWater: false,
|
||||||
|
connections,
|
||||||
|
start,
|
||||||
|
exit,
|
||||||
|
});
|
||||||
|
for (const segment of segments) {
|
||||||
|
this.addConnections(segment, connections);
|
||||||
|
mainRoads.push({ feature: Number(key), cells: segment } as Route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TIME && console.timeEnd("generateMainRoads");
|
||||||
|
return mainRoads;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addConnections(segment: number[], connections: Map<string, boolean>) {
|
||||||
|
for (let i = 0; i < segment.length; i++) {
|
||||||
|
const cellId = segment[i];
|
||||||
|
const nextCellId = segment[i + 1];
|
||||||
|
if (nextCellId) {
|
||||||
|
connections.set(`${cellId}-${nextCellId}`, true);
|
||||||
|
connections.set(`${nextCellId}-${cellId}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateTrails(connections: Map<string, boolean>) {
|
||||||
|
TIME && console.time("generateTrails");
|
||||||
|
const { burgsByFeature } = this.sortBurgsByFeature(pack.burgs);
|
||||||
|
const trails: Route[] = [];
|
||||||
|
|
||||||
|
for (const [key, featureBurgs] of Object.entries(burgsByFeature)) {
|
||||||
|
const points = featureBurgs.map((burg) => [burg.x, burg.y] as Point);
|
||||||
|
const urquhartEdges = this.calculateUrquhartEdges(points);
|
||||||
|
urquhartEdges.forEach(([fromId, toId]) => {
|
||||||
|
const start = featureBurgs[fromId].cell;
|
||||||
|
const exit = featureBurgs[toId].cell;
|
||||||
|
|
||||||
|
const segments = this.findPathSegments({
|
||||||
|
isWater: false,
|
||||||
|
connections,
|
||||||
|
start,
|
||||||
|
exit,
|
||||||
|
});
|
||||||
|
for (const segment of segments) {
|
||||||
|
this.addConnections(segment, connections);
|
||||||
|
trails.push({ feature: Number(key), cells: segment } as Route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TIME && console.timeEnd("generateTrails");
|
||||||
|
return trails;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSeaRoutes(connections: Map<string, boolean>) {
|
||||||
|
TIME && console.time("generateSeaRoutes");
|
||||||
|
const { portsByFeature } = this.sortBurgsByFeature(pack.burgs);
|
||||||
|
const seaRoutes: Route[] = [];
|
||||||
|
|
||||||
|
for (const [featureId, featurePorts] of Object.entries(portsByFeature)) {
|
||||||
|
const points = featurePorts.map((burg) => [burg.x, burg.y] as Point);
|
||||||
|
const urquhartEdges = this.calculateUrquhartEdges(points);
|
||||||
|
|
||||||
|
urquhartEdges.forEach(([fromId, toId]) => {
|
||||||
|
const start = featurePorts[fromId].cell;
|
||||||
|
const exit = featurePorts[toId].cell;
|
||||||
|
const segments = this.findPathSegments({
|
||||||
|
isWater: true,
|
||||||
|
connections,
|
||||||
|
start,
|
||||||
|
exit,
|
||||||
|
});
|
||||||
|
for (const segment of segments) {
|
||||||
|
this.addConnections(segment, connections);
|
||||||
|
seaRoutes.push({
|
||||||
|
feature: Number(featureId),
|
||||||
|
cells: segment,
|
||||||
|
} as Route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TIME && console.timeEnd("generateSeaRoutes");
|
||||||
|
return seaRoutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private preparePointsArray(): Point[] {
|
||||||
|
const { cells, burgs } = pack;
|
||||||
|
return cells.p.map(([x, y], cellId) => {
|
||||||
|
const burgId = cells.burg[cellId];
|
||||||
|
if (burgId) return [burgs[burgId].x, burgs[burgId].y];
|
||||||
|
return [x, y];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPoints(group: string, cells: number[], points: Point[]) {
|
||||||
|
const data = cells.map((cellId) => [...points[cellId], cellId]);
|
||||||
|
|
||||||
|
// resolve sharp angles
|
||||||
|
if (group !== "searoutes") {
|
||||||
|
for (let i = 1; i < cells.length - 1; i++) {
|
||||||
|
const cellId = cells[i];
|
||||||
|
if (pack.cells.burg[cellId]) continue;
|
||||||
|
|
||||||
|
const [prevX, prevY] = data[i - 1];
|
||||||
|
const [currX, currY] = data[i];
|
||||||
|
const [nextX, nextY] = data[i + 1];
|
||||||
|
|
||||||
|
const dAx = prevX - currX;
|
||||||
|
const dAy = prevY - currY;
|
||||||
|
const dBx = nextX - currX;
|
||||||
|
const dBy = nextY - currY;
|
||||||
|
const angle = Math.abs(
|
||||||
|
(Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) /
|
||||||
|
Math.PI,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (angle < ROUTES_SHARP_ANGLE) {
|
||||||
|
const middleX = (prevX + nextX) / 2;
|
||||||
|
const middleY = (prevY + nextY) / 2;
|
||||||
|
let newX: number, newY: number;
|
||||||
|
|
||||||
|
if (angle < ROUTES_VERY_SHARP_ANGLE) {
|
||||||
|
newX = rn((currX + middleX * 2) / 3, 2);
|
||||||
|
newY = rn((currY + middleY * 2) / 3, 2);
|
||||||
|
} else {
|
||||||
|
newX = rn((currX + middleX) / 2, 2);
|
||||||
|
newY = rn((currY + middleY) / 2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findClosestCell(newX, newY, undefined, pack) === cellId) {
|
||||||
|
data[i] = [newX, newY, cellId];
|
||||||
|
points[cellId] = [data[i][0], data[i][1]]; // change cell coordinate for all routes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data; // [[x, y, cell], [x, y, cell]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge routes so that the last cell of one route is the first cell of the next route
|
||||||
|
private mergeRoutes(routes: Route[]): Route[] {
|
||||||
|
let routesMerged = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < routes.length; i++) {
|
||||||
|
const thisRoute = routes[i];
|
||||||
|
if (thisRoute.merged) continue;
|
||||||
|
|
||||||
|
for (let j = i + 1; j < routes.length; j++) {
|
||||||
|
const nextRoute = routes[j];
|
||||||
|
if (nextRoute.merged) continue;
|
||||||
|
|
||||||
|
if (nextRoute.cells!.at(0) === thisRoute.cells!.at(-1)) {
|
||||||
|
routesMerged++;
|
||||||
|
thisRoute.cells = thisRoute.cells!.concat(nextRoute.cells!.slice(1));
|
||||||
|
nextRoute.merged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return routesMerged > 1 ? this.mergeRoutes(routes) : routes;
|
||||||
|
}
|
||||||
|
private createRoutesData(routes: Route[], connections: Map<string, boolean>) {
|
||||||
|
const mainRoads = this.generateMainRoads(connections);
|
||||||
|
const trails = this.generateTrails(connections);
|
||||||
|
const seaRoutes = this.generateSeaRoutes(connections);
|
||||||
|
const pointsArray = this.preparePointsArray();
|
||||||
|
|
||||||
|
for (const { feature, cells, merged } of this.mergeRoutes(mainRoads)) {
|
||||||
|
if (merged) continue;
|
||||||
|
const points = this.getPoints("roads", cells!, pointsArray);
|
||||||
|
routes.push({ i: routes.length, group: "roads", feature, points });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { feature, cells, merged } of this.mergeRoutes(trails)) {
|
||||||
|
if (merged) continue;
|
||||||
|
const points = this.getPoints("trails", cells!, pointsArray);
|
||||||
|
routes.push({ i: routes.length, group: "trails", feature, points });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { feature, cells, merged } of this.mergeRoutes(seaRoutes)) {
|
||||||
|
if (merged) continue;
|
||||||
|
const points = this.getPoints("searoutes", cells!, pointsArray);
|
||||||
|
routes.push({ i: routes.length, group: "searoutes", feature, points });
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(lockedRoutes: Route[] = []) {
|
||||||
|
const connections = new Map();
|
||||||
|
lockedRoutes.forEach((route: Route) => {
|
||||||
|
this.addConnections(
|
||||||
|
route.points.map((p) => p[2]),
|
||||||
|
connections,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
pack.routes = this.createRoutesData(lockedRoutes, connections);
|
||||||
|
pack.cells.routes = this.buildLinks(pack.routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// utility functions
|
||||||
|
isConnected(cellId: number): boolean {
|
||||||
|
const routes = pack.cells.routes;
|
||||||
|
return routes[cellId] && Object.keys(routes[cellId]).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextId() {
|
||||||
|
return pack.routes.length
|
||||||
|
? Math.max(...pack.routes.map((r) => r.i)) + 1
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect cell with routes system by land
|
||||||
|
connect(cellId: number): Route | undefined {
|
||||||
|
const getCost = this.createCostEvaluator({
|
||||||
|
isWater: false,
|
||||||
|
connections: new Map(),
|
||||||
|
});
|
||||||
|
const isExit = (c: number) => isLand(c, pack) && this.isConnected(c);
|
||||||
|
const pathCells = findPath(cellId, isExit, getCost);
|
||||||
|
if (!pathCells) return;
|
||||||
|
|
||||||
|
const pointsArray = this.preparePointsArray();
|
||||||
|
const points = this.getPoints("trails", pathCells, pointsArray);
|
||||||
|
const feature = pack.cells.f[cellId];
|
||||||
|
const routeId = this.getNextId();
|
||||||
|
const newRoute = { i: routeId, group: "trails", feature, points };
|
||||||
|
pack.routes.push(newRoute as Route);
|
||||||
|
|
||||||
|
const addConnection = (from: number, to: number, routeId: number) => {
|
||||||
|
const routes = pack.cells.routes;
|
||||||
|
|
||||||
|
if (!routes[from]) routes[from] = {};
|
||||||
|
routes[from][to] = routeId;
|
||||||
|
|
||||||
|
if (!routes[to]) routes[to] = {};
|
||||||
|
routes[to][from] = routeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < pathCells.length; i++) {
|
||||||
|
const currentCell = pathCells[i];
|
||||||
|
const nextCellId = pathCells[i + 1];
|
||||||
|
if (nextCellId) addConnection(currentCell, nextCellId, routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRoute as Route;
|
||||||
|
}
|
||||||
|
|
||||||
|
areConnected(from: number, to: number): boolean {
|
||||||
|
const routeId = pack.cells.routes[from]?.[to];
|
||||||
|
return routeId !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoute(from: number, to: number) {
|
||||||
|
const routeId = pack.cells.routes[from]?.[to];
|
||||||
|
if (routeId === undefined) return null;
|
||||||
|
|
||||||
|
const route = pack.routes.find((route) => route.i === routeId);
|
||||||
|
if (!route) return null;
|
||||||
|
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRoad(cellId: number): boolean {
|
||||||
|
const connections = pack.cells.routes[cellId];
|
||||||
|
if (!connections) return false;
|
||||||
|
|
||||||
|
return Object.values(connections).some((routeId) => {
|
||||||
|
const route = pack.routes.find((route) => route.i === routeId);
|
||||||
|
if (!route) return false;
|
||||||
|
return route.group === "roads";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isCrossroad(cellId: number): boolean {
|
||||||
|
const connections = pack.cells.routes[cellId];
|
||||||
|
if (!connections) return false;
|
||||||
|
if (Object.keys(connections).length > 3) return true;
|
||||||
|
const roadConnections = Object.values(connections).filter((routeId) => {
|
||||||
|
const route = pack.routes.find((route) => route.i === routeId);
|
||||||
|
return route?.group === "roads";
|
||||||
|
});
|
||||||
|
return roadConnections.length > 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(route: Route) {
|
||||||
|
const routes = pack.cells.routes;
|
||||||
|
|
||||||
|
for (const point of route.points) {
|
||||||
|
const from = point[2];
|
||||||
|
if (!routes[from]) continue;
|
||||||
|
console.log(Object.entries(routes[from]));
|
||||||
|
|
||||||
|
for (const [to, routeId] of Object.entries(routes[from])) {
|
||||||
|
if (routeId === route.i) {
|
||||||
|
delete routes[from][to];
|
||||||
|
delete routes[to][from];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pack.routes = pack.routes.filter((r) => r.i !== route.i);
|
||||||
|
viewbox.select(`#route${route.i}`).remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectivityRate(cellId: number): number {
|
||||||
|
const connections = pack.cells.routes[cellId];
|
||||||
|
if (!connections) return 0;
|
||||||
|
|
||||||
|
const connectivityRateMap = {
|
||||||
|
roads: 0.2,
|
||||||
|
trails: 0.1,
|
||||||
|
searoutes: 0.2,
|
||||||
|
default: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectivity = Object.values(connections).reduce((acc, routeId) => {
|
||||||
|
const route = pack.routes.find((route) => route.i === routeId);
|
||||||
|
if (!route) return acc;
|
||||||
|
const rate =
|
||||||
|
connectivityRateMap[route.group] || connectivityRateMap.default;
|
||||||
|
return acc + rate;
|
||||||
|
}, 0.8);
|
||||||
|
|
||||||
|
return connectivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateName({
|
||||||
|
group,
|
||||||
|
points,
|
||||||
|
}: {
|
||||||
|
group: string;
|
||||||
|
points: number[][];
|
||||||
|
}): string {
|
||||||
|
if (points.length < 4) return "Unnamed route segment";
|
||||||
|
|
||||||
|
function getBurgName() {
|
||||||
|
const priority = [
|
||||||
|
points.at(-1),
|
||||||
|
points.at(0),
|
||||||
|
points.slice(1, -1).reverse(),
|
||||||
|
];
|
||||||
|
for (const [_x, _y, cellId] of priority as [number, number, number][]) {
|
||||||
|
const burgId = pack.cells.burg[cellId as number];
|
||||||
|
if (burgId) return getAdjective(pack.burgs[burgId].name!);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = rw(models[group]);
|
||||||
|
const suffix = rw(suffixes[group]);
|
||||||
|
|
||||||
|
const burgName = getBurgName();
|
||||||
|
if (model === "burg_suffix" && burgName) return `${burgName} ${suffix}`;
|
||||||
|
if (model === "prefix_suffix") return `${ra(prefixes)} ${suffix}`;
|
||||||
|
if (model === "the_descriptor_prefix_suffix")
|
||||||
|
return `The ${ra(descriptors)} ${ra(prefixes)} ${suffix}`;
|
||||||
|
if (model === "the_descriptor_burg_suffix" && burgName)
|
||||||
|
return `The ${ra(descriptors)} ${burgName} ${suffix}`;
|
||||||
|
return "Unnamed route";
|
||||||
|
}
|
||||||
|
|
||||||
|
getPath({ group, points }: { group: string; points: number[][] }): string {
|
||||||
|
const lineGen = line();
|
||||||
|
const ROUTE_CURVES: Record<string, any> = {
|
||||||
|
roads: curveCatmullRom.alpha(0.1),
|
||||||
|
trails: curveCatmullRom.alpha(0.1),
|
||||||
|
searoutes: curveCatmullRom.alpha(0.5),
|
||||||
|
default: curveCatmullRom.alpha(0.1),
|
||||||
|
};
|
||||||
|
lineGen.curve(ROUTE_CURVES[group] || ROUTE_CURVES.default);
|
||||||
|
const path = round(lineGen(points.map((p) => [p[0], p[1]])) as string, 1);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLength(routeId: number): number {
|
||||||
|
const path = routes.select(`#route${routeId}`).node() as SVGPathElement;
|
||||||
|
return path.getTotalLength();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Routes = new RoutesModule();
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Burg } from "../modules/burgs-generator";
|
||||||
import type { Culture } from "../modules/cultures-generator";
|
import type { Culture } from "../modules/cultures-generator";
|
||||||
import type { PackedGraphFeature } from "../modules/features";
|
import type { PackedGraphFeature } from "../modules/features";
|
||||||
import type { River } from "../modules/river-generator";
|
import type { River } from "../modules/river-generator";
|
||||||
|
import type { Route } from "../modules/routes-generator";
|
||||||
|
|
||||||
type TypedArray =
|
type TypedArray =
|
||||||
| Uint8Array
|
| Uint8Array
|
||||||
|
|
@ -20,6 +21,7 @@ export interface PackedGraph {
|
||||||
p: [number, number][]; // cell polygon points
|
p: [number, number][]; // cell polygon points
|
||||||
b: boolean[]; // cell is on border
|
b: boolean[]; // cell is on border
|
||||||
h: TypedArray; // cell heights
|
h: TypedArray; // cell heights
|
||||||
|
/** Terrain type */
|
||||||
t: TypedArray; // cell terrain types
|
t: TypedArray; // cell terrain types
|
||||||
r: TypedArray; // river id passing through cell
|
r: TypedArray; // river id passing through cell
|
||||||
f: TypedArray; // feature id occupying cell
|
f: TypedArray; // feature id occupying cell
|
||||||
|
|
@ -36,6 +38,7 @@ export interface PackedGraph {
|
||||||
religion: TypedArray; // cell religion id
|
religion: TypedArray; // cell religion id
|
||||||
state: number[]; // cell state id
|
state: number[]; // cell state id
|
||||||
area: TypedArray; // cell area
|
area: TypedArray; // cell area
|
||||||
|
routes: Record<number, Record<number, number>>;
|
||||||
};
|
};
|
||||||
vertices: {
|
vertices: {
|
||||||
i: number[]; // vertex indices
|
i: number[]; // vertex indices
|
||||||
|
|
@ -50,4 +53,5 @@ export interface PackedGraph {
|
||||||
burgs: Burg[];
|
burgs: Burg[];
|
||||||
states: any[];
|
states: any[];
|
||||||
cultures: Culture[];
|
cultures: Culture[];
|
||||||
|
routes: Route[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ declare global {
|
||||||
var rivers: Selection<SVGElement, unknown, null, undefined>;
|
var rivers: Selection<SVGElement, unknown, null, undefined>;
|
||||||
var oceanLayers: Selection<SVGGElement, unknown, null, undefined>;
|
var oceanLayers: Selection<SVGGElement, unknown, null, undefined>;
|
||||||
var emblems: Selection<SVGElement, unknown, null, undefined>;
|
var emblems: Selection<SVGElement, unknown, null, undefined>;
|
||||||
|
var viewbox: Selection<SVGElement, unknown, null, undefined>;
|
||||||
|
var routes: Selection<SVGElement, unknown, null, undefined>;
|
||||||
var biomesData: {
|
var biomesData: {
|
||||||
i: number[];
|
i: number[];
|
||||||
name: string[];
|
name: string[];
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue