diff --git a/index.html b/index.html index 9fa9dbc7..7f80d5cc 100644 --- a/index.html +++ b/index.html @@ -6146,7 +6146,7 @@ Data to be copied: heightmap, biomes, religions, population, precipitation, cultures, states, provinces, military regiments

-

Data to be regenerated: zones, roads, rivers

+

Data to be regenerated: zones, routes, rivers

Burgs may be remapped incorrectly, manual change is required

Keep data for:

@@ -8009,10 +8009,12 @@ + + @@ -8035,7 +8037,7 @@ - + diff --git a/main.js b/main.js index efc115cf..7e740baa 100644 --- a/main.js +++ b/main.js @@ -644,6 +644,7 @@ async function generate(options) { Cultures.generate(); Cultures.expand(); BurgsAndStates.generate(); + Routes.generate(); Religions.generate(); BurgsAndStates.defineStateForms(); BurgsAndStates.generateProvinces(); @@ -1652,8 +1653,8 @@ function addZones(number = 1) { used[next.e] = 1; cells.c[next.e].forEach(function (e) { - const r = cells.road[next.e]; - const c = r ? Math.max(10 - r, 1) : 100; + const r = cells.route[next.e]; + const c = r ? 5 : 100; const p = next.p + c; if (p > power) return; @@ -1780,10 +1781,10 @@ function addZones(number = 1) { } function addAvalanche() { - const roads = cells.i.filter(i => !used[i] && cells.road[i] && cells.h[i] >= 70); - if (!roads.length) return; + const routes = cells.i.filter(i => !used[i] && cells.route[i] && cells.h[i] >= 70); + if (!routes.length) return; - const cell = +ra(roads); + const cell = +ra(routes); const cellsArray = [], queue = [cell], power = rand(3, 15); diff --git a/modules/burgs-and-states.js b/modules/burgs-and-states.js index f4f6463a..c9408c62 100644 --- a/modules/burgs-and-states.js +++ b/modules/burgs-and-states.js @@ -6,27 +6,20 @@ window.BurgsAndStates = (function () { const n = cells.i.length; cells.burg = new Uint16Array(n); // cell burg - cells.road = new Uint16Array(n); // cell road power - cells.crossroad = new Uint16Array(n); // cell crossroad power const burgs = (pack.burgs = placeCapitals()); pack.states = createStates(); - const capitalRoutes = Routes.getRoads(); placeTowns(); expandStates(); normalizeStates(); - const townRoutes = Routes.getTrails(); specifyBurgs(); - const oceanRoutes = Routes.getSearoutes(); - collectStatistics(); assignColors(); generateCampaigns(); generateDiplomacy(); - Routes.draw(capitalRoutes, townRoutes, oceanRoutes); drawBurgs(); function placeCapitals() { @@ -167,7 +160,6 @@ window.BurgsAndStates = (function () { const specifyBurgs = function () { TIME && console.time("specifyBurgs"); const cells = pack.cells, - vertices = pack.vertices, features = pack.features, temp = grid.cells.temp; @@ -185,7 +177,7 @@ window.BurgsAndStates = (function () { } else b.port = 0; // define burg population (keep urbanization at about 10% rate) - b.population = rn(Math.max((cells.s[i] + cells.road[i] / 2) / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3); + b.population = rn(Math.max(cells.s[i] / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3); if (b.capital) b.population = rn(b.population * 1.3, 3); // increase capital population if (b.port) { diff --git a/modules/dynamic/export-json.js b/modules/dynamic/export-json.js index c8110b80..1c403bd1 100644 --- a/modules/dynamic/export-json.js +++ b/modules/dynamic/export-json.js @@ -122,8 +122,7 @@ function getPackCellsData() { pop: Array.from(pack.cells.pop), culture: Array.from(pack.cells.culture), burg: Array.from(pack.cells.burg), - road: Array.from(pack.cells.road), - crossroad: Array.from(pack.cells.crossroad), + route: Array.from(pack.cells.route), state: Array.from(pack.cells.state), religion: Array.from(pack.cells.religion), province: Array.from(pack.cells.province) @@ -150,8 +149,7 @@ function getPackCellsData() { pop: dataArrays.pop[cellId], culture: dataArrays.culture[cellId], burg: dataArrays.burg[cellId], - road: dataArrays.road[cellId], - crossroad: dataArrays.crossroad[cellId], + route: dataArrays.route[cellId], state: dataArrays.state[cellId], religion: dataArrays.religion[cellId], province: dataArrays.province[cellId] diff --git a/modules/dynamic/overview/charts-overview.js b/modules/dynamic/overview/charts-overview.js index 35a83b5e..171778a4 100644 --- a/modules/dynamic/overview/charts-overview.js +++ b/modules/dynamic/overview/charts-overview.js @@ -1,5 +1,3 @@ -import {rollups} from "../../../utils/functionUtils.js"; - const entitiesMap = { states: { label: "State", diff --git a/modules/io/load.js b/modules/io/load.js index ff6b2731..239507a4 100644 --- a/modules/io/load.js +++ b/modules/io/load.js @@ -383,12 +383,12 @@ async function parseLoadedData(data, mapVersion) { cells.fl = Uint16Array.from(data[20].split(",")); cells.pop = Float32Array.from(data[21].split(",")); cells.r = Uint16Array.from(data[22].split(",")); - cells.road = Uint16Array.from(data[23].split(",")); + cells.route = Uint8Array.from(data[23].split(",")); cells.s = Uint16Array.from(data[24].split(",")); cells.state = Uint16Array.from(data[25].split(",")); cells.religion = data[26] ? Uint16Array.from(data[26].split(",")) : new Uint16Array(cells.i.length); cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(cells.i.length); - cells.crossroad = data[28] ? Uint16Array.from(data[28].split(",")) : new Uint16Array(cells.i.length); + // data[28] for deprecated cells.crossroad if (data[31]) { const namesDL = data[31].split("/"); diff --git a/modules/io/save.js b/modules/io/save.js index efa1e89a..45bf6018 100644 --- a/modules/io/save.js +++ b/modules/io/save.js @@ -135,12 +135,12 @@ function prepareMapData() { pack.cells.fl, pop, pack.cells.r, - pack.cells.road, + pack.cells.route, pack.cells.s, pack.cells.state, pack.cells.religion, pack.cells.province, - pack.cells.crossroad, + [], // deprecated pack.cells.crossroad religions, provinces, namesData, diff --git a/modules/markers-generator.js b/modules/markers-generator.js index 1ba6a365..585a9153 100644 --- a/modules/markers-generator.js +++ b/modules/markers-generator.js @@ -279,7 +279,7 @@ window.Markers = (function () { } function listInns({cells}) { - return cells.i.filter(i => !occupied[i] && cells.h[i] >= 20 && cells.road[i] > 4 && cells.pop[i] > 10); + return cells.i.filter(i => !occupied[i] && cells.h[i] >= 20 && cells.route[i] === 1 && cells.pop[i] > 10); } function addInn(id, cell) { @@ -542,7 +542,7 @@ window.Markers = (function () { function listLighthouses({cells}) { return cells.i.filter( - i => !occupied[i] && cells.harbor[i] > 6 && cells.c[i].some(c => cells.h[c] < 20 && cells.road[c]) + i => !occupied[i] && cells.harbor[i] > 6 && cells.c[i].some(c => cells.h[c] < 20 && cells.route[c]) ); } @@ -642,7 +642,7 @@ window.Markers = (function () { function listSeaMonsters({cells, features}) { return cells.i.filter( - i => !occupied[i] && cells.h[i] < 20 && cells.road[i] && features[cells.f[i]].type === "ocean" + i => !occupied[i] && cells.h[i] < 20 && cells.route[i] && features[cells.f[i]].type === "ocean" ); } @@ -792,7 +792,7 @@ window.Markers = (function () { cells.religion[i] && cells.biome[i] === 1 && cells.pop[i] > 1 && - cells.road[i] + cells.route[i] ); } @@ -807,7 +807,7 @@ window.Markers = (function () { } function listBrigands({cells}) { - return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.road[i] > 4); + return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.route[i] === 1); } function addBrigands(id, cell) { @@ -867,7 +867,7 @@ window.Markers = (function () { // Pirates spawn on sea routes function listPirates({cells}) { - return cells.i.filter(i => !occupied[i] && cells.h[i] < 20 && cells.road[i]); + return cells.i.filter(i => !occupied[i] && cells.h[i] < 20 && cells.route[i]); } function addPirates(id, cell) { @@ -961,7 +961,7 @@ window.Markers = (function () { } function listCircuses({cells}) { - return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.h[i] >= 20 && pack.cells.road[i]); + return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.h[i] >= 20 && pack.cells.route[i]); } function addCircuse(id, cell) { diff --git a/modules/religions-generator.js b/modules/religions-generator.js index 88857a01..238fcddf 100644 --- a/modules/religions-generator.js +++ b/modules/religions-generator.js @@ -712,9 +712,9 @@ window.Religions = (function () { const religionsMap = new Map(religions.map(r => [r.i, r])); - const isMainRoad = cellId => cells.road[cellId] - cells.crossroad[cellId] > 4; - const isTrail = cellId => cells.h[cellId] > 19 && cells.road[cellId] - cells.crossroad[cellId] === 1; - const isSeaRoute = cellId => cells.h[cellId] < 20 && cells.road[cellId]; + const isMainRoad = cellId => cells.route[cellId] === 1; + const isTrail = cellId => cells.route[cellId] === 2; + const isSeaRoute = cellId => cells.route[cellId] === 3; const isWater = cellId => cells.h[cellId] < 20; while (queue.length) { diff --git a/modules/routes-generator-old.js b/modules/routes-generator-old.js new file mode 100644 index 00000000..3019763d --- /dev/null +++ b/modules/routes-generator-old.js @@ -0,0 +1,273 @@ +window.RoutesOld = (function () { + const getRoads = function () { + TIME && console.time("generateMainRoads"); + const cells = pack.cells; + const burgs = pack.burgs.filter(b => b.i && !b.removed); + const capitals = burgs.filter(b => b.capital).sort((a, b) => a.population - b.population); + + if (capitals.length < 2) return []; // not enough capitals to build main roads + const paths = []; // array to store path segments + + for (const b of capitals) { + const connect = capitals.filter(c => c.feature === b.feature && c !== b); + for (const t of connect) { + const [from, exit] = findLandPath(b.cell, t.cell, true); + const segments = restorePath(b.cell, exit, "main", from); + segments.forEach(s => paths.push(s)); + } + } + + cells.i.forEach(i => (cells.s[i] += cells.route[i] / 2)); // add roads to suitability score + TIME && console.timeEnd("generateMainRoads"); + return paths; + }; + + const getTrails = function () { + TIME && console.time("generateTrails"); + const cells = pack.cells; + const burgs = pack.burgs.filter(b => b.i && !b.removed); + + if (burgs.length < 2) return []; // not enough burgs to build trails + + let paths = []; // array to store path segments + for (const f of pack.features.filter(f => f.land)) { + const isle = burgs.filter(b => b.feature === f.i); // burgs on island + if (isle.length < 2) continue; + + isle.forEach(function (b, i) { + let path = []; + if (!i) { + // build trail from the first burg on island + // to the farthest one on the same island or the closest road + const farthest = d3.scan( + isle, + (a, c) => (c.y - b.y) ** 2 + (c.x - b.x) ** 2 - ((a.y - b.y) ** 2 + (a.x - b.x) ** 2) + ); + const to = isle[farthest].cell; + if (cells.route[to]) return; + const [from, exit] = findLandPath(b.cell, to, true); + path = restorePath(b.cell, exit, "small", from); + } else { + // build trail from all other burgs to the closest road on the same island + if (cells.route[b.cell]) return; + const [from, exit] = findLandPath(b.cell, null, true); + if (exit === null) return; + path = restorePath(b.cell, exit, "small", from); + } + if (path) paths = paths.concat(path); + }); + } + + TIME && console.timeEnd("generateTrails"); + return paths; + }; + + const getSearoutes = function () { + TIME && console.time("generateSearoutes"); + const {cells, burgs, features} = pack; + const allPorts = burgs.filter(b => b.port > 0 && !b.removed); + + if (!allPorts.length) return []; + + const bodies = new Set(allPorts.map(b => b.port)); // water features with ports + let paths = []; // array to store path segments + const connected = []; // store cell id of connected burgs + + bodies.forEach(f => { + const ports = allPorts.filter(b => b.port === f); // all ports on the same feature + if (!ports.length) return; + + if (features[f]?.border) addOverseaRoute(f, ports[0]); + + // get inner-map routes + for (let s = 0; s < ports.length; s++) { + const source = ports[s].cell; + if (connected[source]) continue; + + for (let t = s + 1; t < ports.length; t++) { + const target = ports[t].cell; + if (connected[target]) continue; + + const [from, exit, passable] = findOceanPath(target, source, true); + if (!passable) continue; + + const path = restorePath(target, exit, "ocean", from); + paths = paths.concat(path); + + connected[source] = 1; + connected[target] = 1; + } + } + }); + + function addOverseaRoute(f, port) { + const {x, y, cell: source} = port; + const dist = p => Math.abs(p[0] - x) + Math.abs(p[1] - y); + const [x1, y1] = [ + [0, y], + [x, 0], + [graphWidth, y], + [x, graphHeight] + ].sort((a, b) => dist(a) - dist(b))[0]; + const target = findCell(x1, y1); + + if (cells.f[target] === f && cells.h[target] < 20) { + const [from, exit, passable] = findOceanPath(target, source, true); + + if (passable) { + const path = restorePath(target, exit, "ocean", from); + paths = paths.concat(path); + last(path).push([x1, y1]); + } + } + } + + TIME && console.timeEnd("generateSearoutes"); + return paths; + }; + + const draw = function (main, small, water) { + TIME && console.time("drawRoutes"); + const {cells, burgs} = pack; + const {burg, p} = cells; + + const getBurgCoords = b => [burgs[b].x, burgs[b].y]; + const getPathPoints = cells => cells.map(i => (Array.isArray(i) ? i : burg[i] ? getBurgCoords(burg[i]) : p[i])); + const getPath = segment => round(lineGen(getPathPoints(segment)), 1); + const getPathsHTML = (paths, type) => + paths.map((path, i) => ``).join(""); + + lineGen.curve(d3.curveCatmullRom.alpha(0.1)); + roads.html(getPathsHTML(main, "road")); + trails.html(getPathsHTML(small, "trail")); + + lineGen.curve(d3.curveBundle.beta(1)); + searoutes.html(getPathsHTML(water, "searoute")); + + TIME && console.timeEnd("drawRoutes"); + }; + + const regenerate = function () { + routes.selectAll("path").remove(); + pack.cells.route = new Uint16Array(pack.cells.i.length); + pack.cells.crossroad = new Uint16Array(pack.cells.i.length); + const main = getRoads(); + const small = getTrails(); + const water = getSearoutes(); + draw(main, small, water); + }; + + return {getRoads, getTrails, getSearoutes, draw, regenerate}; + + // Find a land path to a specific cell (exit), to a closest road (toRoad), or to all reachable cells (null, null) + function findLandPath(start, exit = null, toRoad = null) { + const cells = pack.cells; + const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); + const cost = [], + from = []; + queue.queue({e: start, p: 0}); + + while (queue.length) { + const next = queue.dequeue(), + n = next.e, + p = next.p; + if (toRoad && cells.route[n]) return [from, n]; + + for (const c of cells.c[n]) { + if (cells.h[c] < 20) continue; // ignore water cells + const stateChangeCost = cells.state && cells.state[c] !== cells.state[n] ? 400 : 0; // trails tend to lay within the same state + const habitability = biomesData.habitability[cells.biome[c]]; + if (!habitability) continue; // avoid inhabitable cells (eg. lava, glacier) + const habitedCost = habitability ? Math.max(100 - habitability, 0) : 400; // routes tend to lay within populated areas + const heightChangeCost = Math.abs(cells.h[c] - cells.h[n]) * 10; // routes tend to avoid elevation changes + const heightCost = cells.h[c] > 80 ? cells.h[c] : 0; // routes tend to avoid mountainous areas + const cellCoast = 10 + stateChangeCost + habitedCost + heightChangeCost + heightCost; + const totalCost = p + (cells.route[c] || cells.burg[c] ? cellCoast / 3 : cellCoast); + + if (from[c] || totalCost >= cost[c]) continue; + from[c] = n; + if (c === exit) return [from, exit]; + cost[c] = totalCost; + queue.queue({e: c, p: totalCost}); + } + } + return [from, exit]; + } + + function restorePath(start, end, type, from) { + const cells = pack.cells; + const path = []; // to store all segments; + let segment = [], + current = end, + prev = end; + const score = type === "main" ? 5 : 1; // to increase road score at cell + + if (type === "ocean" || !cells.route[prev]) segment.push(end); + if (!cells.route[prev]) cells.route[prev] = score; + + for (let i = 0, limit = 1000; i < limit; i++) { + if (!from[current]) break; + current = from[current]; + + if (cells.route[current]) { + if (segment.length) { + segment.push(current); + path.push(segment); + if (segment[0] !== end) { + cells.route[segment[0]] += score; + cells.crossroad[segment[0]] += score; + } + if (current !== start) { + cells.route[current] += score; + cells.crossroad[current] += score; + } + } + segment = []; + prev = current; + } else { + if (prev) segment.push(prev); + prev = null; + segment.push(current); + } + + cells.route[current] += score; + if (current === start) break; + } + + if (segment.length > 1) path.push(segment); + return path; + } + + // find water paths + function findOceanPath(start, exit = null, toRoute = null) { + const cells = pack.cells, + temp = grid.cells.temp; + const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); + const cost = [], + from = []; + queue.queue({e: start, p: 0}); + + while (queue.length) { + const next = queue.dequeue(), + n = next.e, + p = next.p; + if (toRoute && n !== start && cells.route[n]) return [from, n, true]; + + for (const c of cells.c[n]) { + if (c === exit) { + from[c] = n; + return [from, exit, true]; + } + if (cells.h[c] >= 20) continue; // ignore land cells + if (temp[cells.g[c]] <= -5) continue; // ignore cells with term <= -5 + const dist2 = (cells.p[c][1] - cells.p[n][1]) ** 2 + (cells.p[c][0] - cells.p[n][0]) ** 2; + const totalCost = p + (cells.route[c] ? 1 + dist2 / 2 : dist2 + (cells.t[c] ? 1 : 100)); + + if (from[c] || totalCost >= cost[c]) continue; + (from[c] = n), (cost[c] = totalCost); + queue.queue({e: c, p: totalCost}); + } + } + return [from, exit, false]; + } +})(); diff --git a/modules/routes-generator.js b/modules/routes-generator.js index e4ec3374..de43511a 100644 --- a/modules/routes-generator.js +++ b/modules/routes-generator.js @@ -1,269 +1,320 @@ window.Routes = (function () { - const getRoads = function () { - TIME && console.time("generateMainRoads"); - const cells = pack.cells; - const burgs = pack.burgs.filter(b => b.i && !b.removed); - const capitals = burgs.filter(b => b.capital).sort((a, b) => a.population - b.population); - - if (capitals.length < 2) return []; // not enough capitals to build main roads - const paths = []; // array to store path segments - - for (const b of capitals) { - const connect = capitals.filter(c => c.feature === b.feature && c !== b); - for (const t of connect) { - const [from, exit] = findLandPath(b.cell, t.cell, true); - const segments = restorePath(b.cell, exit, "main", from); - segments.forEach(s => paths.push(s)); - } - } - - cells.i.forEach(i => (cells.s[i] += cells.road[i] / 2)); // add roads to suitability score - TIME && console.timeEnd("generateMainRoads"); - return paths; + const ROUTES = { + MAIN_ROAD: 1, + TRAIL: 2, + SEA_ROUTE: 3 }; - const getTrails = function () { - TIME && console.time("generateTrails"); - const cells = pack.cells; - const burgs = pack.burgs.filter(b => b.i && !b.removed); - - if (burgs.length < 2) return []; // not enough burgs to build trails - - let paths = []; // array to store path segments - for (const f of pack.features.filter(f => f.land)) { - const isle = burgs.filter(b => b.feature === f.i); // burgs on island - if (isle.length < 2) continue; - - isle.forEach(function (b, i) { - let path = []; - if (!i) { - // build trail from the first burg on island - // to the farthest one on the same island or the closest road - const farthest = d3.scan(isle, (a, c) => (c.y - b.y) ** 2 + (c.x - b.x) ** 2 - ((a.y - b.y) ** 2 + (a.x - b.x) ** 2)); - const to = isle[farthest].cell; - if (cells.road[to]) return; - const [from, exit] = findLandPath(b.cell, to, true); - path = restorePath(b.cell, exit, "small", from); - } else { - // build trail from all other burgs to the closest road on the same island - if (cells.road[b.cell]) return; - const [from, exit] = findLandPath(b.cell, null, true); - if (exit === null) return; - path = restorePath(b.cell, exit, "small", from); - } - if (path) paths = paths.concat(path); - }); - } - - TIME && console.timeEnd("generateTrails"); - return paths; - }; - - const getSearoutes = function () { - TIME && console.time("generateSearoutes"); - const {cells, burgs, features} = pack; - const allPorts = burgs.filter(b => b.port > 0 && !b.removed); - - if (!allPorts.length) return []; - - const bodies = new Set(allPorts.map(b => b.port)); // water features with ports - let paths = []; // array to store path segments - const connected = []; // store cell id of connected burgs - - bodies.forEach(f => { - const ports = allPorts.filter(b => b.port === f); // all ports on the same feature - if (!ports.length) return; - - if (features[f]?.border) addOverseaRoute(f, ports[0]); - - // get inner-map routes - for (let s = 0; s < ports.length; s++) { - const source = ports[s].cell; - if (connected[source]) continue; - - for (let t = s + 1; t < ports.length; t++) { - const target = ports[t].cell; - if (connected[target]) continue; - - const [from, exit, passable] = findOceanPath(target, source, true); - if (!passable) continue; - - const path = restorePath(target, exit, "ocean", from); - paths = paths.concat(path); - - connected[source] = 1; - connected[target] = 1; - } - } - }); - - function addOverseaRoute(f, port) { - const {x, y, cell: source} = port; - const dist = p => Math.abs(p[0] - x) + Math.abs(p[1] - y); - const [x1, y1] = [ - [0, y], - [x, 0], - [graphWidth, y], - [x, graphHeight] - ].sort((a, b) => dist(a) - dist(b))[0]; - const target = findCell(x1, y1); - - if (cells.f[target] === f && cells.h[target] < 20) { - const [from, exit, passable] = findOceanPath(target, source, true); - - if (passable) { - const path = restorePath(target, exit, "ocean", from); - paths = paths.concat(path); - last(path).push([x1, y1]); - } - } - } - - TIME && console.timeEnd("generateSearoutes"); - return paths; - }; - - const draw = function (main, small, water) { - TIME && console.time("drawRoutes"); + function generate() { const {cells, burgs} = pack; - const {burg, p} = cells; - const getBurgCoords = b => [burgs[b].x, burgs[b].y]; - const getPathPoints = cells => cells.map(i => (Array.isArray(i) ? i : burg[i] ? getBurgCoords(burg[i]) : p[i])); - const getPath = segment => round(lineGen(getPathPoints(segment)), 1); - const getPathsHTML = (paths, type) => paths.map((path, i) => ``).join(""); + const cellRoutes = new Uint8Array(cells.h.length); - lineGen.curve(d3.curveCatmullRom.alpha(0.1)); - roads.html(getPathsHTML(main, "road")); - trails.html(getPathsHTML(small, "trail")); + const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(burgs); + const connections = new Map(); - lineGen.curve(d3.curveBundle.beta(1)); - searoutes.html(getPathsHTML(water, "searoute")); + const mainRoads = generateMainRoads(); + const trails = generateTrails(); + const seaRoutes = generateSeaRoutes(); - TIME && console.timeEnd("drawRoutes"); - }; + cells.route = cellRoutes; + pack.routes = combineRoutes(); - const regenerate = function () { - routes.selectAll("path").remove(); - pack.cells.road = new Uint16Array(pack.cells.i.length); - pack.cells.crossroad = new Uint16Array(pack.cells.i.length); - const main = getRoads(); - const small = getTrails(); - const water = getSearoutes(); - draw(main, small, water); - }; + function sortBurgsByFeature(burgs) { + const burgsByFeature = {}; + const capitalsByFeature = {}; + const portsByFeature = {}; - return {getRoads, getTrails, getSearoutes, draw, regenerate}; + const addBurg = (object, feature, burg) => { + if (!object[feature]) object[feature] = []; + object[feature].push(burg); + }; - // Find a land path to a specific cell (exit), to a closest road (toRoad), or to all reachable cells (null, null) - function findLandPath(start, exit = null, toRoad = null) { - const cells = pack.cells; - const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); - const cost = [], - from = []; - queue.queue({e: start, p: 0}); + 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); + } + } - while (queue.length) { - const next = queue.dequeue(), - n = next.e, - p = next.p; - if (toRoad && cells.road[n]) return [from, n]; + return {burgsByFeature, capitalsByFeature, portsByFeature}; + } - for (const c of cells.c[n]) { - if (cells.h[c] < 20) continue; // ignore water cells - const stateChangeCost = cells.state && cells.state[c] !== cells.state[n] ? 400 : 0; // trails tend to lay within the same state - const habitability = biomesData.habitability[cells.biome[c]]; - if (!habitability) continue; // avoid inhabitable cells (eg. lava, glacier) - const habitedCost = habitability ? Math.max(100 - habitability, 0) : 400; // routes tend to lay within populated areas - const heightChangeCost = Math.abs(cells.h[c] - cells.h[n]) * 10; // routes tend to avoid elevation changes - const heightCost = cells.h[c] > 80 ? cells.h[c] : 0; // routes tend to avoid mountainous areas - const cellCoast = 10 + stateChangeCost + habitedCost + heightChangeCost + heightCost; - const totalCost = p + (cells.road[c] || cells.burg[c] ? cellCoast / 3 : cellCoast); + function generateMainRoads() { + TIME && console.time("generateMainRoads"); + const mainRoads = []; - if (from[c] || totalCost >= cost[c]) continue; - from[c] = n; - if (c === exit) return [from, exit]; - cost[c] = totalCost; - queue.queue({e: c, p: totalCost}); + 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, cellRoutes, connections, start, exit}); + for (const segment of segments) { + addConnections(segment, ROUTES.MAIN_ROAD); + 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, cellRoutes, connections, start, exit}); + for (const segment of segments) { + addConnections(segment, ROUTES.TRAIL); + trails.push({feature: Number(key), cells: segment}); + } + }); + } + + TIME && console.timeEnd("generateTrails"); + return trails; + } + + function generateSeaRoutes() { + TIME && console.time("generateSearoutes"); + const mainRoads = []; + + for (const [key, 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, cellRoutes, connections, start, exit}); + for (const segment of segments) { + addConnections(segment, ROUTES.SEA_ROUTE); + mainRoads.push({feature: Number(key), cells: segment}); + } + }); + } + + TIME && console.timeEnd("generateSearoutes"); + return mainRoads; + } + + function addConnections(segment, roadTypeId) { + for (let i = 0; i < segment.length; i++) { + const cellId = segment[i]; + const nextCellId = segment[i + 1]; + if (nextCellId) connections.set(`${cellId}-${nextCellId}`, true); + if (!cellRoutes[cellId]) cellRoutes[cellId] = roadTypeId; } } - return [from, exit]; + + function findPathSegments({isWater, cellRoutes, connections, start, exit}) { + const from = findPath(isWater, cellRoutes, start, exit, connections); + if (!from) return []; + + const pathCells = restorePath(start, exit, from); + const segments = getRouteSegments(pathCells, connections); + return segments; + } + + function combineRoutes() { + const routes = []; + + 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}); + } + + for (const {feature, cells} of seaRoutes) { + routes.push({i: routes.length, type: "sea", feature, cells}); + } + + return routes; + } } - function restorePath(start, end, type, from) { - const cells = pack.cells; - const path = []; // to store all segments; - let segment = [], - current = end, - prev = end; - const score = type === "main" ? 5 : 1; // to increase road score at cell + function findPath(isWater, cellRoutes, start, exit, connections) { + const {temp} = grid.cells; + const {cells} = pack; - if (type === "ocean" || !cells.road[prev]) segment.push(end); - if (!cells.road[prev]) cells.road[prev] = score; + const from = []; + const cost = []; + const queue = new FlatQueue(); + queue.push(start, 0); - for (let i = 0, limit = 1000; i < limit; i++) { - if (!from[current]) break; - current = from[current]; + return isWater ? findWaterPath() : findLandPath(); - if (cells.road[current]) { + function findLandPath() { + while (queue.length) { + const priority = queue.peekValue(); + const next = queue.pop(); + + for (const neibCellId of cells.c[next]) { + if (cells.h[neibCellId] < 20) continue; // ignore water cells + + const habitability = biomesData.habitability[cells.biome[neibCellId]]; + if (!habitability) continue; // inhabitable cells are not passable (eg. lava, glacier) + + const distanceCost = dist2(cells.p[next], cells.p[neibCellId]); + + const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1]; + const heightModifier = 1 + Math.max(cells.h[neibCellId] - 50, 0) / 50; // [1, 2]; + const roadModifier = cellRoutes[neibCellId] ? 1 : 2; + const burgModifier = cells.burg[neibCellId] ? 1 : 2; + + const cellsCost = distanceCost * habitabilityModifier * heightModifier * roadModifier * burgModifier; + const totalCost = priority + cellsCost; + + if (from[neibCellId] || totalCost >= cost[neibCellId]) continue; + from[neibCellId] = next; + + if (neibCellId === exit) return from; + + cost[neibCellId] = totalCost; + queue.push(neibCellId, totalCost); + } + } + + return null; // path is not found + } + + function findWaterPath() { + const MIN_PASSABLE_TEMP = -4; + + while (queue.length) { + const priority = queue.peekValue(); + const next = queue.pop(); + + for (const neibCellId of cells.c[next]) { + if (neibCellId === exit) { + from[neibCellId] = next; + return from; + } + + if (cells.h[neibCellId] >= 20) continue; // ignore land cells + if (temp[cells.g[neibCellId]] < MIN_PASSABLE_TEMP) continue; // ignore to cold cells + + const distanceCost = dist2(cells.p[next], cells.p[neibCellId]); + const typeModifier = Math.abs(cells.t[neibCellId]); // 1 for coastline, 2 for deep ocean, 3 for deeper ocean + const routeModifier = cellRoutes[neibCellId] ? 1 : 2; + const connectionModifier = + connections.has(`${next}-${neibCellId}`) || connections.has(`${neibCellId}-${next}`) ? 1 : 3; + + const cellsCost = distanceCost * typeModifier * routeModifier * connectionModifier; + const totalCost = priority + cellsCost; + + if (from[neibCellId] || totalCost >= cost[neibCellId]) continue; + from[neibCellId] = next; + + cost[neibCellId] = totalCost; + queue.push(neibCellId, totalCost); + } + } + + return null; // path is not found + } + } + + function restorePath(start, end, from) { + const cells = []; + + let current = end; + let prev = end; + + while (current !== start) { + cells.push(current); + prev = from[current]; + current = prev; + } + + cells.push(current); + + return cells; + } + + 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.push(current); - path.push(segment); - if (segment[0] !== end) { - cells.road[segment[0]] += score; - cells.crossroad[segment[0]] += score; - } - if (current !== start) { - cells.road[current] += score; - cells.crossroad[current] += score; - } + // segment stepped into existing segment + segment.push(pathCells[i]); + segments.push(segment); + segment = []; } - segment = []; - prev = current; - } else { - if (prev) segment.push(prev); - prev = null; - segment.push(current); + continue; } - cells.road[current] += score; - if (current === start) break; + segment.push(pathCells[i]); } - if (segment.length > 1) path.push(segment); - return path; + if (segment.length > 1) segments.push(segment); + + return segments; } - // find water paths - function findOceanPath(start, exit = null, toRoute = null) { - const cells = pack.cells, - temp = grid.cells.temp; - const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); - const cost = [], - from = []; - queue.queue({e: start, p: 0}); + // 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]); - while (queue.length) { - const next = queue.dequeue(), - n = next.e, - p = next.p; - if (toRoute && n !== start && cells.road[n]) return [from, n, true]; + const {halfedges, triangles} = Delaunator.from(points); + const n = triangles.length; - for (const c of cells.c[n]) { - if (c === exit) { - from[c] = n; - return [from, exit, true]; - } - if (cells.h[c] >= 20) continue; // ignore land cells - if (temp[cells.g[c]] <= -5) continue; // ignore cells with term <= -5 - const dist2 = (cells.p[c][1] - cells.p[n][1]) ** 2 + (cells.p[c][0] - cells.p[n][0]) ** 2; - const totalCost = p + (cells.road[c] ? 1 + dist2 / 2 : dist2 + (cells.t[c] ? 1 : 100)); + const removed = new Uint8Array(n); + const edges = []; - if (from[c] || totalCost >= cost[c]) continue; - (from[c] = n), (cost[c] = totalCost); - queue.queue({e: c, p: totalCost}); + 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 [from, exit, false]; + + return edges; } + + return {generate}; })(); diff --git a/modules/submap.js b/modules/submap.js index 549a7a6f..6dfa6049 100644 --- a/modules/submap.js +++ b/modules/submap.js @@ -145,8 +145,7 @@ window.Submap = (function () { cells.state = new Uint16Array(pn); cells.burg = new Uint16Array(pn); cells.religion = new Uint16Array(pn); - cells.road = new Uint16Array(pn); - cells.crossroad = new Uint16Array(pn); + cells.route = new Uint8Array(pn); cells.province = new Uint16Array(pn); stage("Resampling culture, state and religion map."); @@ -272,7 +271,7 @@ window.Submap = (function () { BurgsAndStates.drawBurgs(); - stage("Regenerating road network."); + stage("Regenerating routes network."); Routes.regenerate(); drawStates(); diff --git a/modules/ui/editors.js b/modules/ui/editors.js index 58c1c18b..b304ce81 100644 --- a/modules/ui/editors.js +++ b/modules/ui/editors.js @@ -143,7 +143,7 @@ function addBurg(point) { const feature = cells.f[cell]; const temple = pack.states[state].form === "Theocracy"; - const population = Math.max((cells.s[cell] + cells.road[cell]) / 3 + i / 1000 + (cell % 100) / 1000, 0.1); + const population = Math.max(cells.s[cell] / 3 + i / 1000 + (cell % 100) / 1000, 0.1); const type = BurgsAndStates.getType(cell, false); // generate emblem @@ -326,7 +326,7 @@ function createMfcgLink(burg) { const citadel = +burg.citadel; const urban_castle = +(citadel && each(2)(i)); - const hub = +cells.road[cell] > 50; + const hub = +cells.route[cell] === 1; const walls = +burg.walls; const plaza = +burg.plaza; @@ -371,7 +371,7 @@ function createVillageGeneratorLink(burg) { else if (cells.r[cell]) tags.push("river"); else if (pop < 200 && each(4)(cell)) tags.push("pond"); - const roadsAround = cells.c[cell].filter(c => cells.h[c] >= 20 && cells.road[c]).length; + const roadsAround = cells.c[cell].filter(c => cells.h[c] >= 20 && cells.route[c]).length; if (roadsAround > 1) tags.push("highway"); else if (roadsAround === 1) tags.push("dead end"); else tags.push("isolated"); diff --git a/modules/ui/heightmap-editor.js b/modules/ui/heightmap-editor.js index 40bc55fb..3a89fe89 100644 --- a/modules/ui/heightmap-editor.js +++ b/modules/ui/heightmap-editor.js @@ -281,8 +281,7 @@ function editHeightmap(options) { const l = grid.cells.i.length; const biome = new Uint8Array(l); const pop = new Uint16Array(l); - const road = new Uint16Array(l); - const crossroad = new Uint16Array(l); + const route = new Uint8Array(l); const s = new Uint16Array(l); const burg = new Uint16Array(l); const state = new Uint16Array(l); @@ -300,8 +299,7 @@ function editHeightmap(options) { biome[g] = pack.cells.biome[i]; culture[g] = pack.cells.culture[i]; pop[g] = pack.cells.pop[i]; - road[g] = pack.cells.road[i]; - crossroad[g] = pack.cells.crossroad[i]; + route[g] = pack.cells.route[i]; s[g] = pack.cells.s[i]; state[g] = pack.cells.state[i]; province[g] = pack.cells.province[i]; @@ -353,8 +351,7 @@ function editHeightmap(options) { // assign saved pack data from grid back to pack const n = pack.cells.i.length; pack.cells.pop = new Float32Array(n); - pack.cells.road = new Uint16Array(n); - pack.cells.crossroad = new Uint16Array(n); + pack.cells.route = new Uint8Array(n); pack.cells.s = new Uint16Array(n); pack.cells.burg = new Uint16Array(n); pack.cells.state = new Uint16Array(n); @@ -389,8 +386,7 @@ function editHeightmap(options) { if (!isLand) continue; pack.cells.culture[i] = culture[g]; pack.cells.pop[i] = pop[g]; - pack.cells.road[i] = road[g]; - pack.cells.crossroad[i] = crossroad[g]; + pack.cells.route[i] = route[g]; pack.cells.s[i] = s[g]; pack.cells.state[i] = state[g]; pack.cells.province[i] = province[g]; diff --git a/modules/ui/measurers.js b/modules/ui/measurers.js index 8120fff1..d2d01c19 100644 --- a/modules/ui/measurers.js +++ b/modules/ui/measurers.js @@ -486,9 +486,7 @@ class RouteOpisometer extends Measurer { const cells = pack.cells; const c = findCell(mousePoint[0], mousePoint[1]); - if (!cells.road[c] && !d3.event.sourceEvent.shiftKey) { - return; - } + if (!cells.route[c] && !d3.event.sourceEvent.shiftKey) return; context.trackCell(c, rigth); }); diff --git a/modules/ui/tools.js b/modules/ui/tools.js index 4d62305c..d743246e 100644 --- a/modules/ui/tools.js +++ b/modules/ui/tools.js @@ -129,7 +129,7 @@ function recalculatePopulation() { if (!b.i || b.removed || b.lock) return; const i = b.cell; - b.population = rn(Math.max((pack.cells.s[i] + pack.cells.road[i] / 2) / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3); + b.population = rn(Math.max(pack.cells.s[i] / 8 + b.i / 1000 + (i % 100) / 1000, 0.1), 3); if (b.capital) b.population = b.population * 1.3; // increase capital population if (b.port) b.population = b.population * 1.3; // increase port population b.population = rn(b.population * gauss(2, 3, 0.6, 20, 3), 3); diff --git a/modules/ui/units-editor.js b/modules/ui/units-editor.js index 97a3573f..6f0332bf 100644 --- a/modules/ui/units-editor.js +++ b/modules/ui/units-editor.js @@ -185,7 +185,7 @@ function editUnits() { const burgs = pack.burgs; const point = d3.mouse(this); const c = findCell(point[0], point[1]); - if (cells.road[c] || d3.event.sourceEvent.shiftKey) { + if (cells.route[c] || d3.event.sourceEvent.shiftKey) { const b = cells.burg[c]; const x = b ? burgs[b].x : cells.p[c][0]; const y = b ? burgs[b].y : cells.p[c][1]; @@ -194,7 +194,7 @@ function editUnits() { d3.event.on("drag", function () { const point = d3.mouse(this); const c = findCell(point[0], point[1]); - if (cells.road[c] || d3.event.sourceEvent.shiftKey) { + if (cells.route[c] || d3.event.sourceEvent.shiftKey) { routeOpisometer.trackCell(c, true); } }); diff --git a/utils/functionUtils.js b/utils/functionUtils.js index 845673a8..83813cdb 100644 --- a/utils/functionUtils.js +++ b/utils/functionUtils.js @@ -1,7 +1,9 @@ +"use strict"; +// FMG helper functions + // extracted d3 code to bypass version conflicts // https://github.com/d3/d3-array/blob/main/src/group.js - -export function rollups(values, reduce, ...keys) { +function rollups(values, reduce, ...keys) { return nest(values, Array.from, reduce, keys); } @@ -23,3 +25,7 @@ function nest(values, map, reduce, keys) { return map(groups); })(values, 0); } + +function dist2([x1, y1], [x2, y2]) { + return (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2); +} diff --git a/versioning.js b/versioning.js index 1574e729..bf275623 100644 --- a/versioning.js +++ b/versioning.js @@ -1,7 +1,7 @@ "use strict"; // version and caching control -const version = "1.97.04"; // generator version, update each time +const version = "1.98.00"; // generator version, update each time { document.title += " v" + version; @@ -28,6 +28,7 @@ const version = "1.97.04"; // generator version, update each time

Join our Discord server and Reddit community to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.