From 454178fa99b3aeadda07d80e25919b17734311be Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 30 Jan 2026 18:29:44 +0100 Subject: [PATCH] refactor: migrate routes (#1294) * 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 --- public/modules/routes-generator.js | 677 --------------- src/index.html | 1 - src/modules/index.ts | 1 + src/modules/routes-generator.ts | 786 ++++++++++++++++++ src/types/PackedGraph.ts | 4 + src/types/global.ts | 2 + .../e2e/layers.spec.ts-snapshots/routes.html | 2 +- 7 files changed, 794 insertions(+), 679 deletions(-) delete mode 100644 public/modules/routes-generator.js create mode 100644 src/modules/routes-generator.ts diff --git a/public/modules/routes-generator.js b/public/modules/routes-generator.js deleted file mode 100644 index 460625ed..00000000 --- a/public/modules/routes-generator.js +++ /dev/null @@ -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 - }; -})(); diff --git a/src/index.html b/src/index.html index 14b61949..f4b605b2 100644 --- a/src/index.html +++ b/src/index.html @@ -8495,7 +8495,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index 660fc100..a3dbe219 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -8,4 +8,5 @@ import "./river-generator"; import "./burgs-generator"; import "./biomes"; import "./cultures-generator"; +import "./routes-generator"; import "./states-generator"; diff --git a/src/modules/routes-generator.ts b/src/modules/routes-generator.ts new file mode 100644 index 00000000..b233db47 --- /dev/null +++ b/src/modules/routes-generator.ts @@ -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 = { + "-1": 1, // coastline + "-2": 1.8, // sea + "-3": 4, // open sea + "-4": 6, // ocean + default: 8, // far ocean +}; + +// name generator data +const models: Record> = { + 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> = { + 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> { + const links: Record> = {}; + + 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 = {}; + const capitalsByFeature: Record = {}; + const portsByFeature: Record = {}; + + const addBurg = ( + collection: Record, + 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; + }) { + 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, + ) { + 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; + 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) { + 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) { + 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) { + 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) { + 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) { + 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 = { + 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(); diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 26bfab5d..d193ead5 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -2,6 +2,7 @@ import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; import type { River } from "../modules/river-generator"; +import type { Route } from "../modules/routes-generator"; import type { State } from "../modules/states-generator"; type TypedArray = @@ -21,6 +22,7 @@ export interface PackedGraph { p: [number, number][]; // cell polygon points b: boolean[]; // cell is on border h: TypedArray; // cell heights + /** Terrain type */ t: TypedArray; // cell terrain types r: TypedArray; // river id passing through cell f: TypedArray; // feature id occupying cell @@ -37,6 +39,7 @@ export interface PackedGraph { religion: TypedArray; // cell religion id state: number[]; // cell state id area: TypedArray; // cell area + routes: Record>; }; vertices: { i: number[]; // vertex indices @@ -51,5 +54,6 @@ export interface PackedGraph { burgs: Burg[]; states: State[]; cultures: Culture[]; + routes: Route[]; religions: any[]; } diff --git a/src/types/global.ts b/src/types/global.ts index fc4cfba9..d0e7fe70 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -30,6 +30,8 @@ declare global { var rivers: Selection; var oceanLayers: Selection; var emblems: Selection; + var viewbox: Selection; + var routes: Selection; var biomesData: { i: number[]; name: string[]; diff --git a/tests/e2e/layers.spec.ts-snapshots/routes.html b/tests/e2e/layers.spec.ts-snapshots/routes.html index 16e6f5ec..5c7688c3 100644 --- a/tests/e2e/layers.spec.ts-snapshots/routes.html +++ b/tests/e2e/layers.spec.ts-snapshots/routes.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file