refactor: migrate routes (#1294)
Some checks failed
Deploy static content to Pages / deploy (push) Has been cancelled
Code quality / quality (push) Has been cancelled

* refactor: migrate routes

* refactor: format findPath call for improved readability

* refactor: update findPath call to include pack parameter

* refactor: optimize route deletion logic in RoutesModule
This commit is contained in:
Marc Emmanuel 2026-01-30 18:29:44 +01:00 committed by GitHub
parent 88c70b9264
commit 454178fa99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 794 additions and 679 deletions

View file

@ -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
};
})();

View file

@ -8495,7 +8495,6 @@
<script defer src="config/precreated-heightmaps.js"></script> <script defer src="config/precreated-heightmaps.js"></script>
<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/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>

View file

@ -8,4 +8,5 @@ import "./river-generator";
import "./burgs-generator"; import "./burgs-generator";
import "./biomes"; import "./biomes";
import "./cultures-generator"; import "./cultures-generator";
import "./routes-generator";
import "./states-generator"; import "./states-generator";

View file

@ -0,0 +1,786 @@
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, pack);
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;
for (const [to, routeId] of Object.entries(routes[from])) {
if (routeId === route.i) {
delete routes[from][parseInt(to, 10)];
delete routes[parseInt(to, 10)][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();

View file

@ -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";
import type { State } from "../modules/states-generator"; import type { State } from "../modules/states-generator";
type TypedArray = type TypedArray =
@ -21,6 +22,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
@ -37,6 +39,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
@ -51,5 +54,6 @@ export interface PackedGraph {
burgs: Burg[]; burgs: Burg[];
states: State[]; states: State[];
cultures: Culture[]; cultures: Culture[];
routes: Route[];
religions: any[]; religions: any[];
} }

View file

@ -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