diff --git a/src/config/generation.ts b/src/config/generation.ts index caf99966..b19f637c 100644 --- a/src/config/generation.ts +++ b/src/config/generation.ts @@ -61,6 +61,6 @@ export const FOREST_BIOMES = [ export const ROUTES = { MAIN_ROAD: 1, - SMALL_ROAD: 2, + TRAIL: 2, SEA_ROUTE: 3 }; diff --git a/src/layers/renderers/drawRoutes.ts b/src/layers/renderers/drawRoutes.ts index ac250537..76b54c68 100644 --- a/src/layers/renderers/drawRoutes.ts +++ b/src/layers/renderers/drawRoutes.ts @@ -6,7 +6,7 @@ export function drawRoutes() { routes.selectAll("path").remove(); const {cells, burgs} = pack; - const lineGen = d3.line().curve(d3.curveBasis); + const lineGen = d3.line().curve(d3.curveCatmullRom.alpha(0.1)); const getBurgCoords = (burgId: number): TPoint => { if (!burgId) throw new Error("burgId must be positive"); @@ -24,8 +24,8 @@ export function drawRoutes() { const routePaths: Dict = {}; - for (const {i, type, cells: routeCells} of pack.routes) { - const points = getPathPoints(routeCells); + for (const {i, type, cells} of pack.routes) { + const points = getPathPoints(cells); const path = round(lineGen(points)!, 1); if (!routePaths[type]) routePaths[type] = []; diff --git a/src/scripts/generation/generation.ts b/src/scripts/generation/generation.ts index 6983f43a..256210e9 100644 --- a/src/scripts/generation/generation.ts +++ b/src/scripts/generation/generation.ts @@ -64,8 +64,8 @@ async function generate(options?: IGenerationOptions) { // temp rendering for debug // renderLayer("cells"); renderLayer("features"); - renderLayer("heightmap"); - renderLayer("rivers"); + // renderLayer("heightmap"); + // renderLayer("rivers"); // renderLayer("biomes"); renderLayer("burgs"); renderLayer("routes"); diff --git a/src/scripts/generation/pack/generateRoutes.ts b/src/scripts/generation/pack/generateRoutes.ts index f0175ad1..21a30342 100644 --- a/src/scripts/generation/pack/generateRoutes.ts +++ b/src/scripts/generation/pack/generateRoutes.ts @@ -1,14 +1,16 @@ +import Delaunator from "delaunator"; import FlatQueue from "flatqueue"; import {TIME} from "config/logging"; import {ROUTES} from "config/generation"; - -const isBurg = (burg: TNoBurg | IBurg): burg is IBurg => burg.i > 0; +import {dist2} from "utils/functionUtils"; +import {drawLine} from "utils/debugUtils"; export function generateRoutes(burgs: TBurgs, cells: Pick) { const cellRoutes = new Uint8Array(cells.h.length); + const validBurgs = burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[]; const mainRoads = generateMainRoads(); - // const townRoutes = getTrails(); + const trails = generateTrails(); // const oceanRoutes = getSearoutes(); const routes = combineRoutes(); @@ -18,50 +20,85 @@ export function generateRoutes(burgs: TBurgs, cells: Pick { - if (!isBurg(burg)) return acc; - const {capital, removed, feature} = burg; - if (!capital || removed) return acc; + const mainRoads: {feature: number; cells: number[]}[] = []; + const capitalsByFeature = validBurgs.reduce((acc, burg) => { + const {capital, feature} = burg; + if (!capital) return acc; if (!acc[feature]) acc[feature] = []; acc[feature].push(burg); return acc; }, {} as {[feature: string]: IBurg[]}); for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) { - for (let i = 0; i < featureCapitals.length; i++) { - const {cell: from} = featureCapitals[i]; + const points: TPoints = featureCapitals.map(burg => [burg.x, burg.y]); + const urquhartEdges = calculateUrquhartEdges(points); + urquhartEdges.forEach(([fromId, toId]) => { + drawLine(points[fromId], points[toId], {stroke: "red", strokeWidth: 0.05}); - for (let j = i + 1; j < featureCapitals.length; j++) { - const {cell: to} = featureCapitals[j]; + const start = featureCapitals[fromId].cell; + const exit = featureCapitals[toId].cell; - const {end, pathCells} = findLandPath({start: from, exit: to}); - if (end !== null && pathCells.length) { - pathCells.forEach(cellId => { - cellRoutes[cellId] = ROUTES.MAIN_ROAD; - }); - mainRoads.push({feature: Number(key), from, to, end, cells: pathCells}); - } + const segments = findLandPathSegments(cellRoutes, start, exit); + for (const segment of segments) { + segment.forEach(cellId => { + cellRoutes[cellId] = ROUTES.MAIN_ROAD; + }); + mainRoads.push({feature: Number(key), cells: segment}); } - } + }); } TIME && console.timeEnd("generateMainRoads"); return mainRoads; } - // find land path to a specific cell or to a closest road - function findLandPath({start, exit}: {start: number; exit: number}) { - const from: number[] = []; - const end = findPath(); - if (end === null) return {end, pathCells: []}; + function generateTrails() { + TIME && console.time("generateTrails"); - const pathCells = restorePath(start, end, from); - return {end, pathCells}; + const trails: {feature: number; cells: number[]}[] = []; + + const burgsByFeature = validBurgs.reduce((acc, burg) => { + const {feature} = burg; + if (!acc[feature]) acc[feature] = []; + acc[feature].push(burg); + return acc; + }, {} as {[feature: string]: IBurg[]}); + + for (const [key, featureBurgs] of Object.entries(burgsByFeature)) { + const points: TPoints = featureBurgs.map(burg => [burg.x, burg.y]); + const urquhartEdges = calculateUrquhartEdges(points); + urquhartEdges.forEach(([fromId, toId]) => { + drawLine(points[fromId], points[toId], {strokeWidth: 0.05}); + + const start = featureBurgs[fromId].cell; + const exit = featureBurgs[toId].cell; + + const segments = findLandPathSegments(cellRoutes, start, exit); + for (const segment of segments) { + segment.forEach(cellId => { + cellRoutes[cellId] = ROUTES.TRAIL; + }); + trails.push({feature: Number(key), cells: segment}); + } + }); + } + + TIME && console.timeEnd("generateTrails"); + return trails; + } + + // find land route segments from cell to cell + function findLandPathSegments(cellRoutes: Uint8Array, start: number, exit: number): number[][] { + const from = findPath(); + if (!from) return []; + + const pathCells = restorePath(start, exit, from); + const segments = getRouteSegments(pathCells, cellRoutes); + return segments; function findPath() { + const from: number[] = []; const cost: number[] = []; const queue = new FlatQueue(); queue.push(start, 0); @@ -70,38 +107,40 @@ export function generateRoutes(burgs: TBurgs, cells: Pick 80 ? cells.h[neibCellId] : 0; // routes tend to avoid mountainous areas const cellCoast = 10 + stateChangeCost + habitedCost + heightChangeCost + heightCost; - const totalCost = priority + (cellRoutes[neibCellId] || cells.burg[neibCellId] ? cellCoast / 3 : cellCoast); + const totalCost = priority + (cellRoutes[neibCellId] || cells.burg[neibCellId] ? cellCoast / 2 : cellCoast); if (from[neibCellId] || totalCost >= cost[neibCellId]) continue; from[neibCellId] = next; - if (neibCellId === exit) return exit; + if (neibCellId === exit) return from; cost[neibCellId] = totalCost; queue.push(neibCellId, totalCost); } } - return null; + return null; // path is not found } } function combineRoutes() { const routes: TRoutes = []; - for (const {feature, from, to, end, cells} of mainRoads) { - routes.push({i: routes.length, type: "road", feature, from, to, end, cells}); + for (const {feature, cells} of mainRoads) { + routes.push({i: routes.length, type: "road", feature, cells}); + } + + for (const {feature, cells} of trails) { + routes.push({i: routes.length, type: "trail", feature, cells}); } return routes; @@ -115,10 +154,84 @@ function restorePath(start: number, end: number, from: number[]) { let prev = end; while (current !== start) { - prev = from[current]; cells.push(current); + prev = from[current]; current = prev; } + cells.push(current); + return cells; } + +function getRouteSegments(pathCells: number[], cellRoutes: Uint8Array) { + const hasRoute = (cellId: number) => cellRoutes[cellId] !== 0; + const noRoute = (cellId: number) => cellRoutes[cellId] === 0; + + const segments: number[][] = []; + let segment: number[] = []; + + // UC: complitely new route + if (pathCells.every(noRoute)) return [pathCells]; + + // UC: all cells already have route + if (pathCells.every(hasRoute)) return []; + + // UC: only first and/or last cell have route + if (pathCells.slice(1, -1).every(noRoute)) return [pathCells]; + + // UC: discontinuous route + for (let i = 0; i < pathCells.length; i++) { + const cellId = pathCells[i]; + const nextCellId = pathCells[i + 1]; + + const hasRoute = cellRoutes[cellId] !== 0; + const nextHasRoute = cellRoutes[nextCellId] !== 0; + + const noConnection = !hasRoute || !nextHasRoute; + if (noConnection) segment.push(cellId); + } + + 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: TPoints) { + const score = (p0: number, p1: number) => 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; +} diff --git a/src/types/pack/routes.d.ts b/src/types/pack/routes.d.ts index 0577f92c..64d780cf 100644 --- a/src/types/pack/routes.d.ts +++ b/src/types/pack/routes.d.ts @@ -2,9 +2,6 @@ interface IRoute { i: number; type: "road" | "trail" | "sea"; feature: number; - from: number; - to: number; - end: number; cells: number[]; } diff --git a/src/utils/debugUtils.ts b/src/utils/debugUtils.ts index 59aaef10..727db4ed 100644 --- a/src/utils/debugUtils.ts +++ b/src/utils/debugUtils.ts @@ -17,6 +17,17 @@ export function drawPolygon( .attr("stroke-width", strokeWidth); } +export function drawLine([x1, y1]: TPoint, [x2, y2]: TPoint, {stroke = "#444", strokeWidth = 0.2} = {}) { + debug + .append("line") + .attr("x1", x1) + .attr("y1", y1) + .attr("x2", x2) + .attr("y2", y2) + .attr("stroke", stroke) + .attr("stroke-width", strokeWidth); +} + export function drawArrow([x1, y1]: TPoint, [x2, y2]: TPoint, {width = 1, color = "#444"} = {}): void { const angle = Math.atan2(y2 - y1, x2 - x1); const normal = angle + Math.PI / 2;