From 6776e5b867c20dc9fa9fae6d1b671fe6aeb46277 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 24 Mar 2024 20:10:11 +0100 Subject: [PATCH 01/37] feat: routes generation --- index.html | 6 +- main.js | 11 +- modules/burgs-and-states.js | 10 +- modules/dynamic/export-json.js | 6 +- modules/dynamic/overview/charts-overview.js | 2 - modules/io/load.js | 4 +- modules/io/save.js | 4 +- modules/markers-generator.js | 14 +- modules/religions-generator.js | 6 +- modules/routes-generator-old.js | 273 +++++++++++ modules/routes-generator.js | 493 +++++++++++--------- modules/submap.js | 5 +- modules/ui/editors.js | 6 +- modules/ui/heightmap-editor.js | 12 +- modules/ui/measurers.js | 4 +- modules/ui/tools.js | 2 +- modules/ui/units-editor.js | 4 +- utils/functionUtils.js | 10 +- versioning.js | 6 +- 19 files changed, 595 insertions(+), 283 deletions(-) create mode 100644 modules/routes-generator-old.js diff --git a/index.html b/index.html index 9fa9dbc71..7f80d5cc1 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 efc115cf5..7e740baae 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 f4f6463a8..c9408c62c 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 c8110b80b..1c403bd18 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 35a83b5ea..171778a45 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 ff6b27312..239507a44 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 efa1e89a0..45bf6018d 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 1ba6a365e..585a91538 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 88857a011..238fcddfe 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 000000000..3019763d5 --- /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 e4ec33748..de43511aa 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)); + const ROUTES = { + MAIN_ROAD: 1, + TRAIL: 2, + SEA_ROUTE: 3 + }; + + function generate() { + const {cells, burgs} = pack; + + const cellRoutes = new Uint8Array(cells.h.length); + + const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(burgs); + const connections = new Map(); + + const mainRoads = generateMainRoads(); + const trails = generateTrails(); + const seaRoutes = generateSeaRoutes(); + + cells.route = cellRoutes; + pack.routes = combineRoutes(); + + function sortBurgsByFeature(burgs) { + const burgsByFeature = {}; + const capitalsByFeature = {}; + const portsByFeature = {}; + + const addBurg = (object, feature, burg) => { + if (!object[feature]) object[feature] = []; + object[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}; } - cells.i.forEach(i => (cells.s[i] += cells.road[i] / 2)); // add roads to suitability score - TIME && console.timeEnd("generateMainRoads"); - return paths; - }; + 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, cellRoutes, connections, start, exit}); + for (const segment of segments) { + addConnections(segment, ROUTES.MAIN_ROAD); + mainRoads.push({feature: Number(key), cells: segment}); + } + }); + } - 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("generateMainRoads"); + return mainRoads; } - TIME && console.timeEnd("generateTrails"); - return paths; - }; + function generateTrails() { + TIME && console.time("generateTrails"); - const getSearoutes = function () { - TIME && console.time("generateSearoutes"); - const {cells, burgs, features} = pack; - const allPorts = burgs.filter(b => b.port > 0 && !b.removed); + const trails = []; - if (!allPorts.length) return []; + 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 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 + 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}); + } + }); + } - bodies.forEach(f => { - const ports = allPorts.filter(b => b.port === f); // all ports on the same feature - if (!ports.length) return; + TIME && console.timeEnd("generateTrails"); + return trails; + } - if (features[f]?.border) addOverseaRoute(f, ports[0]); + 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}); + } + }); + } - // get inner-map routes - for (let s = 0; s < ports.length; s++) { - const source = ports[s].cell; - if (connected[source]) continue; + TIME && console.timeEnd("generateSearoutes"); + return mainRoads; + } - for (let t = s + 1; t < ports.length; t++) { - const target = ports[t].cell; - if (connected[target]) continue; + 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; + } + } - const [from, exit, passable] = findOceanPath(target, source, true); - if (!passable) continue; + function findPathSegments({isWater, cellRoutes, connections, start, exit}) { + const from = findPath(isWater, cellRoutes, start, exit, connections); + if (!from) return []; - const path = restorePath(target, exit, "ocean", from); - paths = paths.concat(path); + const pathCells = restorePath(start, exit, from); + const segments = getRouteSegments(pathCells, connections); + return segments; + } - connected[source] = 1; - connected[target] = 1; - } + 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}); } - }); - - 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]); + + return routes; + } + } + + function findPath(isWater, cellRoutes, start, exit, connections) { + const {temp} = grid.cells; + const {cells} = pack; + + const from = []; + const cost = []; + const queue = new FlatQueue(); + queue.push(start, 0); + + return isWater ? findWaterPath() : findLandPath(); + + 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 } - TIME && console.timeEnd("generateSearoutes"); - return paths; - }; + function findWaterPath() { + const MIN_PASSABLE_TEMP = -4; - const draw = function (main, small, water) { - TIME && console.time("drawRoutes"); - const {cells, burgs} = pack; - const {burg, p} = cells; + while (queue.length) { + const priority = queue.peekValue(); + const next = queue.pop(); - 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(""); + for (const neibCellId of cells.c[next]) { + if (neibCellId === exit) { + from[neibCellId] = next; + return from; + } - lineGen.curve(d3.curveCatmullRom.alpha(0.1)); - roads.html(getPathsHTML(main, "road")); - trails.html(getPathsHTML(small, "trail")); + if (cells.h[neibCellId] >= 20) continue; // ignore land cells + if (temp[cells.g[neibCellId]] < MIN_PASSABLE_TEMP) continue; // ignore to cold cells - lineGen.curve(d3.curveBundle.beta(1)); - searoutes.html(getPathsHTML(water, "searoute")); + 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; - TIME && console.timeEnd("drawRoutes"); - }; + const cellsCost = distanceCost * typeModifier * routeModifier * connectionModifier; + const totalCost = priority + cellsCost; - 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); - }; + if (from[neibCellId] || totalCost >= cost[neibCellId]) continue; + from[neibCellId] = next; - 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.road[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.road[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}); + cost[neibCellId] = totalCost; + queue.push(neibCellId, totalCost); + } } + + return null; // path is not found } - 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 + 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; + } - if (type === "ocean" || !cells.road[prev]) segment.push(end); - if (!cells.road[prev]) cells.road[prev] = score; + function getRouteSegments(pathCells, connections) { + const segments = []; + let segment = []; - for (let i = 0, limit = 1000; i < limit; i++) { - if (!from[current]) break; - current = from[current]; + 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 (cells.road[current]) { + 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}); - - while (queue.length) { - const next = queue.dequeue(), - n = next.e, - p = next.p; - if (toRoute && n !== start && cells.road[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.road[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}); + // 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 [from, exit, false]; + + return edges; } + + return {generate}; })(); diff --git a/modules/submap.js b/modules/submap.js index 549a7a6fc..6dfa6049e 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 58c1c18bf..b304ce819 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 40bc55fb3..3a89fe891 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 8120fff13..d2d01c198 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 4d62305c9..d743246e5 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 97a3573ff..6f0332bf8 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 845673a8d..83813cdb3 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 1574e7291..bf275623a 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
    Latest changes: +
  • New routes generatation algorithm
  • Preview villages map
  • Ability to render ocean heightmap
  • Scale bar styling features
  • @@ -40,9 +41,6 @@ const version = "1.97.04"; // generator version, update each time
  • North and South Poles temperature can be set independently
  • More than 70 new heraldic charges
  • Multi-color heraldic charges support
  • -
  • New 3D scene options and improvements
  • -
  • Autosave feature (in Options)
  • -
  • Google translation support (in Options)

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

From 71e53bd34f1fbab2092db4174c957f4cd5bf9fbc Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 24 Mar 2024 22:43:37 +0100 Subject: [PATCH 02/37] feat: routes rendering --- index.html | 1 + modules/dynamic/auto-update.js | 8 ++ modules/routes-generator.js | 6 +- modules/ui/layers.js | 132 +++++++++++++++++++++++++++++++-- 4 files changed, 138 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index 7f80d5cc1..94a12ef22 100644 --- a/index.html +++ b/index.html @@ -612,6 +612,7 @@ id="toggleRoutes" data-tip="Trade routes: click to toggle, drag to raise or lower the layer. Ctrl + click to edit layer style" data-shortcut="U" + class="buttonoff" onclick="toggleRoutes(event)" > Routes diff --git a/modules/dynamic/auto-update.js b/modules/dynamic/auto-update.js index 85f623f9a..ab156f187 100644 --- a/modules/dynamic/auto-update.js +++ b/modules/dynamic/auto-update.js @@ -843,4 +843,12 @@ export function resolveVersionConflicts(version) { } }); } + + if (version < 1.98) { + // v1.98.00 changed routes generation algorithm and data format + // 1. cells.road => cells.route; 1 = MAIN; 2 = TRAIL; 3 = SEA; + // 2. cells.crossroad is removed + // 3. pack.routes is added + // 4. rendering is changed + } } diff --git a/modules/routes-generator.js b/modules/routes-generator.js index de43511aa..84c88eb48 100644 --- a/modules/routes-generator.js +++ b/modules/routes-generator.js @@ -134,15 +134,15 @@ window.Routes = (function () { const routes = []; for (const {feature, cells} of mainRoads) { - routes.push({i: routes.length, type: "road", feature, cells}); + routes.push({i: routes.length, group: "roads", feature, cells}); } for (const {feature, cells} of trails) { - routes.push({i: routes.length, type: "trail", feature, cells}); + routes.push({i: routes.length, group: "trails", feature, cells}); } for (const {feature, cells} of seaRoutes) { - routes.push({i: routes.length, type: "sea", feature, cells}); + routes.push({i: routes.length, group: "searoutes", feature, cells}); } return routes; diff --git a/modules/ui/layers.js b/modules/ui/layers.js index 9db58faa2..e48afffa2 100644 --- a/modules/ui/layers.js +++ b/modules/ui/layers.js @@ -169,6 +169,7 @@ function restoreLayers() { if (layerIsOn("toggleGrid")) drawGrid(); if (layerIsOn("toggleCoordinates")) drawCoordinates(); if (layerIsOn("toggleCompass")) compass.style("display", "block"); + if (layerIsOn("toggleRoutes")) drawRoutes(); if (layerIsOn("toggleTemp")) drawTemp(); if (layerIsOn("togglePrec")) drawPrec(); if (layerIsOn("togglePopulation")) drawPopulation(); @@ -1624,18 +1625,137 @@ function drawRivers() { function toggleRoutes(event) { if (!layerIsOn("toggleRoutes")) { turnButtonOn("toggleRoutes"); - $("#routes").fadeIn(); + drawRoutes(); if (event && isCtrlClick(event)) editStyle("routes"); } else { - if (event && isCtrlClick(event)) { - editStyle("routes"); - return; - } - $("#routes").fadeOut(); + if (event && isCtrlClick(event)) return editStyle("routes"); + routes.selectAll("path").remove(); turnButtonOff("toggleRoutes"); } } +function drawRoutes() { + TIME && console.time("drawRoutes"); + const {cells, burgs} = pack; + const lineGen = d3.line(); + + const SHARP_ANGLE = 135; + const VERY_SHARP_ANGLE = 115; + + const points = adjustBurgPoints(); // mutable array of points + const routePaths = {}; + + const lineGenMap = { + roads: d3.curveCatmullRom.alpha(0.1), + trails: d3.curveCatmullRom.alpha(0.1), + searoutes: d3.curveBasis, + default: d3.curveCatmullRom.alpha(0.1) + }; + + for (const {i, group, cells} of pack.routes) { + if (group !== "searoutes") straightenPathAngles(cells); // mutates points + const pathPoints = getPathPoints(cells); + + lineGen.curve(lineGenMap[group] || lineGenMap.default); + const path = round(lineGen(pathPoints), 1); + + if (!routePaths[group]) routePaths[group] = []; + routePaths[group].push(``); + } + + routes.selectAll("path").remove(); + for (const group in routePaths) { + routes.select("#" + group).html(routePaths[group].join("")); + } + + TIME && console.timeEnd("drawRoutes"); + + function adjustBurgPoints() { + const points = Array.from(cells.p); + + for (const burg of burgs) { + if (burg.i === 0) continue; + const {cell, x, y} = burg; + points[cell] = [x, y]; + } + + return points; + } + + function straightenPathAngles(cellIds) { + for (let i = 1; i < cellIds.length - 1; i++) { + const cellId = cellIds[i]; + if (cells.burg[cellId]) continue; + + const prev = points[cellIds[i - 1]]; + const that = points[cellId]; + const next = points[cellIds[i + 1]]; + + const dAx = prev[0] - that[0]; + const dAy = prev[1] - that[1]; + const dBx = next[0] - that[0]; + const dBy = next[1] - that[1]; + const angle = (Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy) * 180) / Math.PI; + + if (Math.abs(angle) < SHARP_ANGLE) { + const middleX = (prev[0] + next[0]) / 2; + const middleY = (prev[1] + next[1]) / 2; + + if (Math.abs(angle) < VERY_SHARP_ANGLE) { + const newX = (that[0] + middleX * 2) / 3; + const newY = (that[1] + middleY * 2) / 3; + points[cellId] = [newX, newY]; + continue; + } + + const newX = (that[0] + middleX) / 2; + const newY = (that[1] + middleY) / 2; + points[cellId] = [newX, newY]; + } + } + } + + function getPathPoints(cellIds) { + const pathPoints = cellIds.map(cellId => points[cellId]); + + if (pathPoints.length === 2) { + // curve and shorten 2-points line + const [[x1, y1], [x2, y2]] = pathPoints; + + const middleX = (x1 + x2) / 2; + const middleY = (y1 + y2) / 2; + + // add shifted point at the middle to curve the line a bit + const NORMAL_LENGTH = 0.3; + const normal = getNormal([x1, y1], [x2, y2]); + const sign = cellIds[0] % 2 ? 1 : -1; + const normalX = middleX + NORMAL_LENGTH * Math.cos(normal) * sign; + const normalY = middleY + NORMAL_LENGTH * Math.sin(normal) * sign; + + // make line shorter to avoid overlapping with other lines + const SHORT_LINE_LENGTH_MODIFIER = 0.8; + const distX = x2 - x1; + const distY = y2 - y1; + const nx1 = x1 + distX * SHORT_LINE_LENGTH_MODIFIER; + const ny1 = y1 + distY * SHORT_LINE_LENGTH_MODIFIER; + const nx2 = x2 - distX * SHORT_LINE_LENGTH_MODIFIER; + const ny2 = y2 - distY * SHORT_LINE_LENGTH_MODIFIER; + + return [ + [nx1, ny1], + [normalX, normalY], + [nx2, ny2] + ]; + } + + return pathPoints; + } + + function getNormal([x1, y1], [x2, y2]) { + return Math.atan2(y1 - y2, x1 - x2) + Math.PI / 2; + } +} + function toggleMilitary() { if (!layerIsOn("toggleMilitary")) { turnButtonOn("toggleMilitary"); From dfd80f2c81f4b0c2643f23faccb3db82f06401d3 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Fri, 26 Apr 2024 02:52:27 +0200 Subject: [PATCH 03/37] feat: searoutes fix, changing reGraph --- main.js | 23 ++++++--- modules/dynamic/editors/states-editor.js | 2 +- modules/routes-generator.js | 64 +++++++++++++----------- modules/ui/biomes-editor.js | 2 +- modules/ui/heightmap-editor.js | 1 + modules/ui/layers.js | 25 +++++++-- modules/ui/provinces-editor.js | 2 +- modules/ui/zones-editor.js | 64 ++++++++++++++++++------ 8 files changed, 127 insertions(+), 56 deletions(-) diff --git a/main.js b/main.js index 7e740baae..7a5ae7a52 100644 --- a/main.js +++ b/main.js @@ -1169,15 +1169,26 @@ function reGraph() { for (const i of gridCells.i) { const height = gridCells.h[i]; const type = gridCells.t[i]; - if (height < 20 && type !== -1 && type !== -2) continue; // exclude all deep ocean points - if (type === -2 && (i % 4 === 0 || features[gridCells.f[i]].type === "lake")) continue; // exclude non-coastal lake points - const [x, y] = points[i]; + const isOnBorder = gridCells.b[i]; + + // exclude most of ocean points + if (height < 20) { + const isLake = features[gridCells.f[i]].type === "lake"; + if (isLake && type !== -1) continue; + + if (type === 0) continue; + if (type < -4 && !each(24)(i)) continue; + if (type === -4 && !each(12)(i)) continue; + if (type === -3 && !each(6)(i)) continue; + if (type === -2 && !each(3)(i)) continue; + } + const [x, y] = points[i]; addNewPoint(i, x, y, height); // add additional points for cells along coast if (type === 1 || type === -1) { - if (gridCells.b[i]) continue; // not for near-border cells + if (isOnBorder) continue; // not for near-border cells gridCells.c[i].forEach(function (e) { if (i > e) return; if (gridCells.t[e] === type) { @@ -1402,8 +1413,8 @@ function reMarkFeatures() { queue[0] = cells.f.findIndex(f => !f); // find unmarked cell } - // markupPackLand - markup(pack.cells, 3, 1, 0); + markup(pack.cells, 3, 1, 0); // markupPackLand + markup(pack.cells, -2, -1, -10); // markupPackWater function defineHaven(i) { const water = cells.c[i].filter(c => cells.h[c] < 20); diff --git a/modules/dynamic/editors/states-editor.js b/modules/dynamic/editors/states-editor.js index 70a45017e..83480f17d 100644 --- a/modules/dynamic/editors/states-editor.js +++ b/modules/dynamic/editors/states-editor.js @@ -966,7 +966,7 @@ function dragStateBrush() { const p = d3.mouse(this); moveCircle(p[0], p[1], r); - const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)]; + const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])]; const selection = found.filter(isLand); if (selection) changeStateForSelection(selection); }); diff --git a/modules/routes-generator.js b/modules/routes-generator.js index 84c88eb48..44b33f664 100644 --- a/modules/routes-generator.js +++ b/modules/routes-generator.js @@ -7,7 +7,6 @@ window.Routes = (function () { function generate() { const {cells, burgs} = pack; - const cellRoutes = new Uint8Array(cells.h.length); const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(burgs); @@ -53,7 +52,7 @@ window.Routes = (function () { const start = featureCapitals[fromId].cell; const exit = featureCapitals[toId].cell; - const segments = findPathSegments({isWater: false, cellRoutes, connections, start, exit}); + const segments = findPathSegments({isWater: false, connections, start, exit}); for (const segment of segments) { addConnections(segment, ROUTES.MAIN_ROAD); mainRoads.push({feature: Number(key), cells: segment}); @@ -67,7 +66,6 @@ window.Routes = (function () { function generateTrails() { TIME && console.time("generateTrails"); - const trails = []; for (const [key, featureBurgs] of Object.entries(burgsByFeature)) { @@ -77,7 +75,7 @@ window.Routes = (function () { const start = featureBurgs[fromId].cell; const exit = featureBurgs[toId].cell; - const segments = findPathSegments({isWater: false, cellRoutes, connections, start, exit}); + const segments = findPathSegments({isWater: false, connections, start, exit}); for (const segment of segments) { addConnections(segment, ROUTES.TRAIL); trails.push({feature: Number(key), cells: segment}); @@ -90,39 +88,43 @@ window.Routes = (function () { } function generateSeaRoutes() { - TIME && console.time("generateSearoutes"); - const mainRoads = []; + TIME && console.time("generateSeaRoutes"); + const seaRoutes = []; - for (const [key, featurePorts] of Object.entries(portsByFeature)) { + for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { const points = featurePorts.map(burg => [burg.x, burg.y]); const urquhartEdges = calculateUrquhartEdges(points); + console.log(urquhartEdges); urquhartEdges.forEach(([fromId, toId]) => { const start = featurePorts[fromId].cell; const exit = featurePorts[toId].cell; - const segments = findPathSegments({isWater: true, cellRoutes, connections, start, exit}); + const segments = findPathSegments({isWater: true, connections, start, exit}); for (const segment of segments) { addConnections(segment, ROUTES.SEA_ROUTE); - mainRoads.push({feature: Number(key), cells: segment}); + seaRoutes.push({feature: Number(featureId), cells: segment}); } }); } - TIME && console.timeEnd("generateSearoutes"); - return mainRoads; + TIME && console.timeEnd("generateSeaRoutes"); + return seaRoutes; } - function addConnections(segment, roadTypeId) { + function addConnections(segment, routeTypeId) { 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; + if (nextCellId) { + connections.set(`${cellId}-${nextCellId}`, true); + connections.set(`${nextCellId}-${cellId}`, true); + } + if (!cellRoutes[cellId]) cellRoutes[cellId] = routeTypeId; } } - function findPathSegments({isWater, cellRoutes, connections, start, exit}) { - const from = findPath(isWater, cellRoutes, start, exit, connections); + function findPathSegments({isWater, connections, start, exit}) { + const from = findPath(isWater, start, exit, connections); if (!from) return []; const pathCells = restorePath(start, exit, from); @@ -149,7 +151,17 @@ window.Routes = (function () { } } - function findPath(isWater, cellRoutes, start, exit, connections) { + const MIN_PASSABLE_SEA_TEMP = -4; + + const TYPE_MODIFIERS = { + "-1": 1, // coastline + "-2": 1.8, // sea + "-3": 3, // open sea + "-4": 5, // ocean + default: 8 // far ocean + }; + + function findPath(isWater, start, exit, connections) { const {temp} = grid.cells; const {cells} = pack; @@ -174,11 +186,11 @@ window.Routes = (function () { 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 heightModifier = 1 + Math.max(cells.h[neibCellId] - 25, 25) / 25; // [1, 3]; + const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 3; const burgModifier = cells.burg[neibCellId] ? 1 : 2; - const cellsCost = distanceCost * habitabilityModifier * heightModifier * roadModifier * burgModifier; + const cellsCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier; const totalCost = priority + cellsCost; if (from[neibCellId] || totalCost >= cost[neibCellId]) continue; @@ -195,8 +207,6 @@ window.Routes = (function () { } function findWaterPath() { - const MIN_PASSABLE_TEMP = -4; - while (queue.length) { const priority = queue.peekValue(); const next = queue.pop(); @@ -208,15 +218,13 @@ window.Routes = (function () { } if (cells.h[neibCellId] >= 20) continue; // ignore land cells - if (temp[cells.g[neibCellId]] < MIN_PASSABLE_TEMP) continue; // ignore to cold cells + if (temp[cells.g[neibCellId]] < MIN_PASSABLE_SEA_TEMP) continue; // ignore too 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 typeModifier = TYPE_MODIFIERS[cells.t[neibCellId]] || TYPE_MODIFIERS.default; + const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 2; - const cellsCost = distanceCost * typeModifier * routeModifier * connectionModifier; + const cellsCost = distanceCost * typeModifier * connectionModifier; const totalCost = priority + cellsCost; if (from[neibCellId] || totalCost >= cost[neibCellId]) continue; diff --git a/modules/ui/biomes-editor.js b/modules/ui/biomes-editor.js index bcb6c2068..8f2400f0e 100644 --- a/modules/ui/biomes-editor.js +++ b/modules/ui/biomes-editor.js @@ -390,7 +390,7 @@ function editBiomes() { const p = d3.mouse(this); moveCircle(p[0], p[1], r); - const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)]; + const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])]; const selection = found.filter(isLand); if (selection) changeBiomeForSelection(selection); }); diff --git a/modules/ui/heightmap-editor.js b/modules/ui/heightmap-editor.js index 3a89fe891..c99e4bddb 100644 --- a/modules/ui/heightmap-editor.js +++ b/modules/ui/heightmap-editor.js @@ -246,6 +246,7 @@ function editHeightmap(options) { Cultures.expand(); BurgsAndStates.generate(); + Routes.generate(); Religions.generate(); BurgsAndStates.defineStateForms(); BurgsAndStates.generateProvinces(); diff --git a/modules/ui/layers.js b/modules/ui/layers.js index e48afffa2..b018ed101 100644 --- a/modules/ui/layers.js +++ b/modules/ui/layers.js @@ -1637,7 +1637,6 @@ function toggleRoutes(event) { function drawRoutes() { TIME && console.time("drawRoutes"); const {cells, burgs} = pack; - const lineGen = d3.line(); const SHARP_ANGLE = 135; const VERY_SHARP_ANGLE = 115; @@ -1645,10 +1644,11 @@ function drawRoutes() { const points = adjustBurgPoints(); // mutable array of points const routePaths = {}; - const lineGenMap = { + const lineGen = d3.line(); + const curves = { roads: d3.curveCatmullRom.alpha(0.1), trails: d3.curveCatmullRom.alpha(0.1), - searoutes: d3.curveBasis, + searoutes: d3.curveCatmullRom.alpha(0.5), default: d3.curveCatmullRom.alpha(0.1) }; @@ -1656,7 +1656,24 @@ function drawRoutes() { if (group !== "searoutes") straightenPathAngles(cells); // mutates points const pathPoints = getPathPoints(cells); - lineGen.curve(lineGenMap[group] || lineGenMap.default); + // TODO: temporary view for searoutes + if (group === "searoutes2") { + const pathPoints = cells.map(cellId => points[cellId]); + const color = getMixedColor("#000000", 0.6); + const line = "M" + pathPoints.join("L"); + pathPoints.forEach(([x, y]) => + debug.append("circle").attr("r", 0.7).attr("cx", x).attr("cy", y).attr("fill", color) + ); + if (!routePaths[group]) routePaths[group] = []; + routePaths[group].push(``); + + lineGen.curve(curves[group] || curves.default); + const path = round(lineGen(pathPoints), 1); + routePaths[group].push(` `); + continue; + } + + lineGen.curve(curves[group] || curves.default); const path = round(lineGen(pathPoints), 1); if (!routePaths[group]) routePaths[group] = []; diff --git a/modules/ui/provinces-editor.js b/modules/ui/provinces-editor.js index a9a2dfa0b..8ef848e6f 100644 --- a/modules/ui/provinces-editor.js +++ b/modules/ui/provinces-editor.js @@ -886,7 +886,7 @@ function editProvinces() { const p = d3.mouse(this); moveCircle(p[0], p[1], r); - const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)]; + const found = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])]; const selection = found.filter(isLand); if (selection) changeForSelection(selection); }); diff --git a/modules/ui/zones-editor.js b/modules/ui/zones-editor.js index 759447dda..2b8245ea2 100644 --- a/modules/ui/zones-editor.js +++ b/modules/ui/zones-editor.js @@ -61,7 +61,8 @@ function editZones() { const filterSelect = document.getElementById("zonesFilterType"); const typeToFilterBy = types.includes(zonesFilterType.value) ? zonesFilterType.value : "all"; - filterSelect.innerHTML = "" + types.map(type => ``).join(""); + filterSelect.innerHTML = + "" + types.map(type => ``).join(""); filterSelect.value = typeToFilterBy; } @@ -80,9 +81,12 @@ function editZones() { const fill = zoneEl.getAttribute("fill"); const area = getArea(d3.sum(c.map(i => pack.cells.area[i]))); const rural = d3.sum(c.map(i => pack.cells.pop[i])) * populationRate; - const urban = d3.sum(c.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization; + const urban = + d3.sum(c.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization; const population = rural + urban; - const populationTip = `Total population: ${si(population)}; Rural population: ${si(rural)}; Urban population: ${si(urban)}. Click to change`; + const populationTip = `Total population: ${si(population)}; Rural population: ${si( + rural + )}; Urban population: ${si(urban)}. Click to change`; const inactive = zoneEl.style.display === "none"; const focused = defs.select("#fog #focus" + zoneEl.id).size(); @@ -98,8 +102,12 @@ function editZones() {
${si(population)}
- - + + `; }); @@ -109,7 +117,9 @@ function editZones() { // update footer const totalArea = getArea(graphWidth * graphHeight); zonesFooterArea.dataset.area = totalArea; - const totalPop = (d3.sum(pack.cells.pop) + d3.sum(pack.burgs.filter(b => !b.removed).map(b => b.population)) * urbanization) * populationRate; + const totalPop = + (d3.sum(pack.cells.pop) + d3.sum(pack.burgs.filter(b => !b.removed).map(b => b.population)) * urbanization) * + populationRate; zonesFooterPopulation.dataset.population = totalPop; zonesFooterNumber.innerHTML = /* html */ `${filteredZones.length} of ${zones.length}`; zonesFooterCells.innerHTML = pack.cells.i.length; @@ -150,7 +160,13 @@ function editZones() { zonesEditorAddLines(); } - $(body).sortable({items: "div.states", handle: ".icon-resize-vertical", containment: "parent", axis: "y", update: movezone}); + $(body).sortable({ + items: "div.states", + handle: ".icon-resize-vertical", + containment: "parent", + axis: "y", + update: movezone + }); function movezone(ev, ui) { const zone = $("#" + ui.item.attr("data-id")); const prev = $("#" + ui.item.prev().attr("data-id")); @@ -174,7 +190,11 @@ function editZones() { $("#zonesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}}); tip("Click to select a zone, drag to paint a zone", true); - viewbox.style("cursor", "crosshair").on("click", selectZoneOnMapClick).call(d3.drag().on("start", dragZoneBrush)).on("touchmove mousemove", moveZoneBrush); + viewbox + .style("cursor", "crosshair") + .on("click", selectZoneOnMapClick) + .call(d3.drag().on("start", dragZoneBrush)) + .on("touchmove mousemove", moveZoneBrush); body.querySelector("div").classList.add("selected"); zones.selectAll("g").each(function () { @@ -202,7 +222,7 @@ function editZones() { const p = d3.mouse(this); moveCircle(p[0], p[1], r); - const selection = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1], r)]; + const selection = r > 5 ? findAll(p[0], p[1], r) : [findCell(p[0], p[1])]; if (!selection) return; const selected = body.querySelector("div.selected"); @@ -285,7 +305,8 @@ function editZones() { zonesEditor.querySelectorAll(".hide:not(.show)").forEach(el => el.classList.remove("hidden")); zonesFooter.style.display = "block"; body.querySelectorAll("div > input, select, svg").forEach(e => (e.style.pointerEvents = "all")); - if (!close) $("#zonesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}}); + if (!close) + $("#zonesEditor").dialog({position: {my: "right top", at: "right-10 top+10", of: "svg", collision: "fit"}}); restoreDefaultEvents(); clearMainTip(); @@ -356,7 +377,8 @@ function editZones() { body.querySelectorAll(":scope > div").forEach(function (el) { el.querySelector(".stateCells").innerHTML = rn((+el.dataset.cells / totalCells) * 100, 2) + "%"; el.querySelector(".biomeArea").innerHTML = rn((+el.dataset.area / totalArea) * 100, 2) + "%"; - el.querySelector(".culturePopulation").innerHTML = rn((+el.dataset.population / totalPopulation) * 100, 2) + "%"; + el.querySelector(".culturePopulation").innerHTML = + rn((+el.dataset.population / totalPopulation) * 100, 2) + "%"; }); } else { body.dataset.type = "absolute"; @@ -369,7 +391,13 @@ function editZones() { const description = "Unknown zone"; const type = "Unknown"; const fill = "url(#hatch" + (id.slice(4) % 42) + ")"; - zones.append("g").attr("id", id).attr("data-description", description).attr("data-type", type).attr("data-cells", "").attr("fill", fill); + zones + .append("g") + .attr("id", id) + .attr("data-description", description) + .attr("data-type", type) + .attr("data-cells", "") + .attr("fill", fill); zonesEditorAddLines(); } @@ -411,13 +439,19 @@ function editZones() { const burgs = pack.burgs.filter(b => !b.removed && cells.includes(b.cell)); const rural = rn(d3.sum(cells.map(i => pack.cells.pop[i])) * populationRate); - const urban = rn(d3.sum(cells.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization); + const urban = rn( + d3.sum(cells.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization + ); const total = rural + urban; const l = n => Number(n).toLocaleString(); alertMessage.innerHTML = /* html */ `Rural: Urban: - -

Total population: ${l(total)} ⇒ ${l(total)} (100%)

`; + +

Total population: ${l(total)} ⇒ ${l( + total + )} (100%)

`; const update = function () { const totalNew = ruralPop.valueAsNumber + urbanPop.valueAsNumber; From 22edfb0dec554b6d3ef0399b8b2122bd47c490d4 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 27 Apr 2024 13:33:33 +0200 Subject: [PATCH 04/37] feat: searoute - change pathfinding algo --- modules/routes-generator-old.js | 273 -------------------------------- modules/routes-generator.js | 60 ++++++- modules/ui/layers.js | 12 +- 3 files changed, 64 insertions(+), 281 deletions(-) delete mode 100644 modules/routes-generator-old.js diff --git a/modules/routes-generator-old.js b/modules/routes-generator-old.js deleted file mode 100644 index 3019763d5..000000000 --- a/modules/routes-generator-old.js +++ /dev/null @@ -1,273 +0,0 @@ -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 44b33f664..dcaac9782 100644 --- a/modules/routes-generator.js +++ b/modules/routes-generator.js @@ -1,3 +1,20 @@ +// suggested data format + +// pack.cells.connectivity = { +// cellId1: { +// toCellId2: routeId2, +// toCellId3: routeId2, +// }, +// cellId2: { +// toCellId1: routeId2, +// toCellId3: routeId1, +// } +// } + +// pack.routes = [ +// {i, group: "roads", feature: featureId, cells: [cellId], points?: [[x, y], [x, y]]} +// ]; + window.Routes = (function () { const ROUTES = { MAIN_ROAD: 1, @@ -91,14 +108,36 @@ window.Routes = (function () { TIME && console.time("generateSeaRoutes"); const seaRoutes = []; + let skip = false; + for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { const points = featurePorts.map(burg => [burg.x, burg.y]); const urquhartEdges = calculateUrquhartEdges(points); - console.log(urquhartEdges); + urquhartEdges.forEach(([fromId, toId]) => { const start = featurePorts[fromId].cell; const exit = featurePorts[toId].cell; + if (skip) return; + if (start === 444 && exit === 297) { + // if (segment.join(",") === "124,122,120") debugger; + skip = true; + + for (const con of connections) { + const [from, to] = con[0].split("-").map(Number); + const [x1, y1] = cells.p[from]; + const [x2, y2] = cells.p[to]; + debug + .append("line") + .attr("x1", x1) + .attr("y1", y1) + .attr("x2", x2) + .attr("y2", y2) + .attr("stroke", "red") + .attr("stroke-width", 0.2); + } + } + const segments = findPathSegments({isWater: true, connections, start, exit}); for (const segment of segments) { addConnections(segment, ROUTES.SEA_ROUTE); @@ -170,6 +209,8 @@ window.Routes = (function () { const queue = new FlatQueue(); queue.push(start, 0); + const isDebug = start === 444 && exit === 297; + return isWater ? findWaterPath() : findLandPath(); function findLandPath() { @@ -188,7 +229,7 @@ window.Routes = (function () { const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1]; const heightModifier = 1 + Math.max(cells.h[neibCellId] - 25, 25) / 25; // [1, 3]; const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 3; - const burgModifier = cells.burg[neibCellId] ? 1 : 2; + const burgModifier = cells.burg[neibCellId] ? 1 : 3; const cellsCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier; const totalCost = priority + cellsCost; @@ -210,13 +251,16 @@ window.Routes = (function () { while (queue.length) { const priority = queue.peekValue(); const next = queue.pop(); + isDebug && console.log("next", next); for (const neibCellId of cells.c[next]) { if (neibCellId === exit) { + isDebug && console.log(`neib ${neibCellId} is exit`); from[neibCellId] = next; return from; } + // if (from[neibCellId]) continue; // don't go back if (cells.h[neibCellId] >= 20) continue; // ignore land cells if (temp[cells.g[neibCellId]] < MIN_PASSABLE_SEA_TEMP) continue; // ignore too cold cells @@ -227,7 +271,17 @@ window.Routes = (function () { const cellsCost = distanceCost * typeModifier * connectionModifier; const totalCost = priority + cellsCost; - if (from[neibCellId] || totalCost >= cost[neibCellId]) continue; + if (isDebug) { + const lost = totalCost >= cost[neibCellId]; + console.log( + `neib ${neibCellId}`, + `cellCost ${rn(cellsCost)}`, + `new ${rn(totalCost)} ${lost ? ">=" : "<"} prev ${rn(cost[neibCellId])}.`, + `${lost ? "lost" : "won"}` + ); + } + + if (totalCost >= cost[neibCellId]) continue; from[neibCellId] = next; cost[neibCellId] = totalCost; diff --git a/modules/ui/layers.js b/modules/ui/layers.js index b018ed101..ad826bc78 100644 --- a/modules/ui/layers.js +++ b/modules/ui/layers.js @@ -1653,11 +1653,11 @@ function drawRoutes() { }; for (const {i, group, cells} of pack.routes) { - if (group !== "searoutes") straightenPathAngles(cells); // mutates points + // if (group !== "searoutes") straightenPathAngles(cells); // mutates points const pathPoints = getPathPoints(cells); // TODO: temporary view for searoutes - if (group === "searoutes2") { + if (group) { const pathPoints = cells.map(cellId => points[cellId]); const color = getMixedColor("#000000", 0.6); const line = "M" + pathPoints.join("L"); @@ -1667,9 +1667,9 @@ function drawRoutes() { if (!routePaths[group]) routePaths[group] = []; routePaths[group].push(``); - lineGen.curve(curves[group] || curves.default); - const path = round(lineGen(pathPoints), 1); - routePaths[group].push(` `); + // lineGen.curve(curves[group] || curves.default); + // const path = round(lineGen(pathPoints), 1); + // routePaths[group].push(` `); continue; } @@ -1685,6 +1685,8 @@ function drawRoutes() { routes.select("#" + group).html(routePaths[group].join("")); } + drawCellsValue(pack.cells.i); + TIME && console.timeEnd("drawRoutes"); function adjustBurgPoints() { From 597f6ddd75e762a2450d4881c3675ad88d79bf09 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 27 Apr 2024 21:53:41 +0200 Subject: [PATCH 05/37] feat: routes - cleanup code --- modules/routes-generator.js | 57 +++++------------------------- modules/ui/layers.js | 69 +++---------------------------------- 2 files changed, 13 insertions(+), 113 deletions(-) diff --git a/modules/routes-generator.js b/modules/routes-generator.js index dcaac9782..9dbd6679e 100644 --- a/modules/routes-generator.js +++ b/modules/routes-generator.js @@ -108,8 +108,6 @@ window.Routes = (function () { TIME && console.time("generateSeaRoutes"); const seaRoutes = []; - let skip = false; - for (const [featureId, featurePorts] of Object.entries(portsByFeature)) { const points = featurePorts.map(burg => [burg.x, burg.y]); const urquhartEdges = calculateUrquhartEdges(points); @@ -117,27 +115,6 @@ window.Routes = (function () { urquhartEdges.forEach(([fromId, toId]) => { const start = featurePorts[fromId].cell; const exit = featurePorts[toId].cell; - - if (skip) return; - if (start === 444 && exit === 297) { - // if (segment.join(",") === "124,122,120") debugger; - skip = true; - - for (const con of connections) { - const [from, to] = con[0].split("-").map(Number); - const [x1, y1] = cells.p[from]; - const [x2, y2] = cells.p[to]; - debug - .append("line") - .attr("x1", x1) - .attr("y1", y1) - .attr("x2", x2) - .attr("y2", y2) - .attr("stroke", "red") - .attr("stroke-width", 0.2); - } - } - const segments = findPathSegments({isWater: true, connections, start, exit}); for (const segment of segments) { addConnections(segment, ROUTES.SEA_ROUTE); @@ -195,8 +172,8 @@ window.Routes = (function () { const TYPE_MODIFIERS = { "-1": 1, // coastline "-2": 1.8, // sea - "-3": 3, // open sea - "-4": 5, // ocean + "-3": 4, // open sea + "-4": 6, // ocean default: 8 // far ocean }; @@ -209,8 +186,6 @@ window.Routes = (function () { const queue = new FlatQueue(); queue.push(start, 0); - const isDebug = start === 444 && exit === 297; - return isWater ? findWaterPath() : findLandPath(); function findLandPath() { @@ -219,26 +194,26 @@ window.Routes = (function () { const next = queue.pop(); for (const neibCellId of cells.c[next]) { - if (cells.h[neibCellId] < 20) continue; // ignore water cells + if (neibCellId === exit) { + from[neibCellId] = next; + return from; + } + 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] - 25, 25) / 25; // [1, 3]; - const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 3; + const connectionModifier = connections.has(`${next}-${neibCellId}`) ? 1 : 2; const burgModifier = cells.burg[neibCellId] ? 1 : 3; const cellsCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier; const totalCost = priority + cellsCost; - if (from[neibCellId] || totalCost >= cost[neibCellId]) continue; + if (totalCost >= cost[neibCellId]) continue; from[neibCellId] = next; - - if (neibCellId === exit) return from; - cost[neibCellId] = totalCost; queue.push(neibCellId, totalCost); } @@ -251,16 +226,13 @@ window.Routes = (function () { while (queue.length) { const priority = queue.peekValue(); const next = queue.pop(); - isDebug && console.log("next", next); for (const neibCellId of cells.c[next]) { if (neibCellId === exit) { - isDebug && console.log(`neib ${neibCellId} is exit`); from[neibCellId] = next; return from; } - // if (from[neibCellId]) continue; // don't go back if (cells.h[neibCellId] >= 20) continue; // ignore land cells if (temp[cells.g[neibCellId]] < MIN_PASSABLE_SEA_TEMP) continue; // ignore too cold cells @@ -271,19 +243,8 @@ window.Routes = (function () { const cellsCost = distanceCost * typeModifier * connectionModifier; const totalCost = priority + cellsCost; - if (isDebug) { - const lost = totalCost >= cost[neibCellId]; - console.log( - `neib ${neibCellId}`, - `cellCost ${rn(cellsCost)}`, - `new ${rn(totalCost)} ${lost ? ">=" : "<"} prev ${rn(cost[neibCellId])}.`, - `${lost ? "lost" : "won"}` - ); - } - if (totalCost >= cost[neibCellId]) continue; from[neibCellId] = next; - cost[neibCellId] = totalCost; queue.push(neibCellId, totalCost); } diff --git a/modules/ui/layers.js b/modules/ui/layers.js index ad826bc78..34906c639 100644 --- a/modules/ui/layers.js +++ b/modules/ui/layers.js @@ -393,7 +393,6 @@ function drawTemp() { const start = findStart(i, t); if (!start) continue; used[i] = 1; - //debug.append("circle").attr("r", 3).attr("cx", vertices.p[start][0]).attr("cy", vertices.p[start][1]).attr("fill", "red").attr("stroke", "black").attr("stroke-width", .3); const chain = connectVertices(start, t); // vertices chain to form a path const relaxed = chain.filter((v, i) => i % 4 === 0 || vertices.c[v].some(c => c >= n)); @@ -1638,9 +1637,6 @@ function drawRoutes() { TIME && console.time("drawRoutes"); const {cells, burgs} = pack; - const SHARP_ANGLE = 135; - const VERY_SHARP_ANGLE = 115; - const points = adjustBurgPoints(); // mutable array of points const routePaths = {}; @@ -1651,27 +1647,12 @@ function drawRoutes() { searoutes: d3.curveCatmullRom.alpha(0.5), default: d3.curveCatmullRom.alpha(0.1) }; + const SHARP_ANGLE = 135; + const VERY_SHARP_ANGLE = 115; for (const {i, group, cells} of pack.routes) { - // if (group !== "searoutes") straightenPathAngles(cells); // mutates points - const pathPoints = getPathPoints(cells); - - // TODO: temporary view for searoutes - if (group) { - const pathPoints = cells.map(cellId => points[cellId]); - const color = getMixedColor("#000000", 0.6); - const line = "M" + pathPoints.join("L"); - pathPoints.forEach(([x, y]) => - debug.append("circle").attr("r", 0.7).attr("cx", x).attr("cy", y).attr("fill", color) - ); - if (!routePaths[group]) routePaths[group] = []; - routePaths[group].push(``); - - // lineGen.curve(curves[group] || curves.default); - // const path = round(lineGen(pathPoints), 1); - // routePaths[group].push(` `); - continue; - } + if (group !== "searoutes") straightenPathAngles(cells); // mutates points + const pathPoints = cells.map(cellId => points[cellId]); lineGen.curve(curves[group] || curves.default); const path = round(lineGen(pathPoints), 1); @@ -1685,8 +1666,6 @@ function drawRoutes() { routes.select("#" + group).html(routePaths[group].join("")); } - drawCellsValue(pack.cells.i); - TIME && console.timeEnd("drawRoutes"); function adjustBurgPoints() { @@ -1733,46 +1712,6 @@ function drawRoutes() { } } } - - function getPathPoints(cellIds) { - const pathPoints = cellIds.map(cellId => points[cellId]); - - if (pathPoints.length === 2) { - // curve and shorten 2-points line - const [[x1, y1], [x2, y2]] = pathPoints; - - const middleX = (x1 + x2) / 2; - const middleY = (y1 + y2) / 2; - - // add shifted point at the middle to curve the line a bit - const NORMAL_LENGTH = 0.3; - const normal = getNormal([x1, y1], [x2, y2]); - const sign = cellIds[0] % 2 ? 1 : -1; - const normalX = middleX + NORMAL_LENGTH * Math.cos(normal) * sign; - const normalY = middleY + NORMAL_LENGTH * Math.sin(normal) * sign; - - // make line shorter to avoid overlapping with other lines - const SHORT_LINE_LENGTH_MODIFIER = 0.8; - const distX = x2 - x1; - const distY = y2 - y1; - const nx1 = x1 + distX * SHORT_LINE_LENGTH_MODIFIER; - const ny1 = y1 + distY * SHORT_LINE_LENGTH_MODIFIER; - const nx2 = x2 - distX * SHORT_LINE_LENGTH_MODIFIER; - const ny2 = y2 - distY * SHORT_LINE_LENGTH_MODIFIER; - - return [ - [nx1, ny1], - [normalX, normalY], - [nx2, ny2] - ]; - } - - return pathPoints; - } - - function getNormal([x1, y1], [x2, y2]) { - return Math.atan2(y1 - y2, x1 - x2) + Math.PI / 2; - } } function toggleMilitary() { From b47fa6b92db373426b414abf5476353ef00d1eb4 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 28 Apr 2024 00:52:49 +0200 Subject: [PATCH 06/37] feat: routes - change data format --- main.js | 20 ++++---- modules/biomes.js | 22 ++------ modules/dynamic/auto-update.js | 4 +- modules/dynamic/export-json.js | 48 +++++++++--------- modules/io/load.js | 4 +- modules/io/save.js | 8 ++- modules/markers-generator.js | 37 +++++++------- modules/religions-generator.js | 28 +++++----- modules/routes-generator.js | 93 +++++++++++++++++++++------------- modules/submap.js | 1 - modules/ui/editors.js | 13 ++--- modules/ui/heightmap-editor.js | 8 +-- modules/ui/measurers.js | 2 +- modules/ui/units-editor.js | 6 ++- 14 files changed, 155 insertions(+), 139 deletions(-) diff --git a/main.js b/main.js index 7a5ae7a52..d28ceac51 100644 --- a/main.js +++ b/main.js @@ -1652,9 +1652,10 @@ function addZones(number = 1) { const burg = ra(burgs.filter(b => !used[b.cell] && b.i && !b.removed)); // random burg if (!burg) return; - const cellsArray = [], - cost = [], - power = rand(20, 37); + const cellsArray = []; + const cost = []; + const power = rand(20, 37); + const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); queue.queue({e: burg.cell, p: 0}); @@ -1663,15 +1664,14 @@ function addZones(number = 1) { if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); used[next.e] = 1; - cells.c[next.e].forEach(function (e) { - const r = cells.route[next.e]; - const c = r ? 5 : 100; + cells.c[next.e].forEach(nextCellId => { + const c = Routes.getRoute(next.e, nextCellId) ? 5 : 100; const p = next.p + c; if (p > power) return; - if (!cost[e] || p < cost[e]) { - cost[e] = p; - queue.queue({e, p}); + if (!cost[nextCellId] || p < cost[nextCellId]) { + cost[nextCellId] = p; + queue.queue({e: nextCellId, p}); } }); } @@ -1792,7 +1792,7 @@ function addZones(number = 1) { } function addAvalanche() { - const routes = cells.i.filter(i => !used[i] && cells.route[i] && cells.h[i] >= 70); + const routes = cells.i.filter(i => !used[i] && Routes.isConnected(i) && cells.h[i] >= 70); if (!routes.length) return; const cell = +ra(routes); diff --git a/modules/biomes.js b/modules/biomes.js index d7c95f77b..06280fad0 100644 --- a/modules/biomes.js +++ b/modules/biomes.js @@ -1,24 +1,8 @@ "use strict"; -const MIN_LAND_HEIGHT = 20; - -const names = [ - "Marine", - "Hot desert", - "Cold desert", - "Savanna", - "Grassland", - "Tropical seasonal forest", - "Temperate deciduous forest", - "Tropical rainforest", - "Temperate rainforest", - "Taiga", - "Tundra", - "Glacier", - "Wetland" -]; - window.Biomes = (function () { + const MIN_LAND_HEIGHT = 20; + const getDefault = () => { const name = [ "Marine", @@ -52,7 +36,7 @@ window.Biomes = (function () { "#0b9131" ]; const habitability = [0, 4, 10, 22, 30, 50, 100, 80, 90, 12, 4, 0, 12]; - const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 150]; + const iconsDensity = [0, 3, 2, 120, 120, 120, 120, 150, 150, 100, 5, 0, 250]; const icons = [ {}, {dune: 3, cactus: 6, deadTree: 1}, diff --git a/modules/dynamic/auto-update.js b/modules/dynamic/auto-update.js index ab156f187..7b3a4eed8 100644 --- a/modules/dynamic/auto-update.js +++ b/modules/dynamic/auto-update.js @@ -846,9 +846,9 @@ export function resolveVersionConflicts(version) { if (version < 1.98) { // v1.98.00 changed routes generation algorithm and data format - // 1. cells.road => cells.route; 1 = MAIN; 2 = TRAIL; 3 = SEA; + // 1. cells.road => cells.routes and now it an object of objects {i1: {i2: routeId, i3: routeId}} // 2. cells.crossroad is removed - // 3. pack.routes is added + // 3. pack.routes is added as an array of objects // 4. rendering is changed } } diff --git a/modules/dynamic/export-json.js b/modules/dynamic/export-json.js index 1c403bd18..88cead8cb 100644 --- a/modules/dynamic/export-json.js +++ b/modules/dynamic/export-json.js @@ -103,7 +103,7 @@ function getSettings() { } function getPackCellsData() { - const dataArrays = { + const data = { v: pack.cells.v, c: pack.cells.c, p: pack.cells.p, @@ -122,7 +122,7 @@ function getPackCellsData() { pop: Array.from(pack.cells.pop), culture: Array.from(pack.cells.culture), burg: Array.from(pack.cells.burg), - route: Array.from(pack.cells.route), + routes: pack.cells.routes, state: Array.from(pack.cells.state), religion: Array.from(pack.cells.religion), province: Array.from(pack.cells.province) @@ -131,28 +131,28 @@ function getPackCellsData() { return { cells: Array.from(pack.cells.i).map(cellId => ({ i: cellId, - v: dataArrays.v[cellId], - c: dataArrays.c[cellId], - p: dataArrays.p[cellId], - g: dataArrays.g[cellId], - h: dataArrays.h[cellId], - area: dataArrays.area[cellId], - f: dataArrays.f[cellId], - t: dataArrays.t[cellId], - haven: dataArrays.haven[cellId], - harbor: dataArrays.harbor[cellId], - fl: dataArrays.fl[cellId], - r: dataArrays.r[cellId], - conf: dataArrays.conf[cellId], - biome: dataArrays.biome[cellId], - s: dataArrays.s[cellId], - pop: dataArrays.pop[cellId], - culture: dataArrays.culture[cellId], - burg: dataArrays.burg[cellId], - route: dataArrays.route[cellId], - state: dataArrays.state[cellId], - religion: dataArrays.religion[cellId], - province: dataArrays.province[cellId] + v: data.v[cellId], + c: data.c[cellId], + p: data.p[cellId], + g: data.g[cellId], + h: data.h[cellId], + area: data.area[cellId], + f: data.f[cellId], + t: data.t[cellId], + haven: data.haven[cellId], + harbor: data.harbor[cellId], + fl: data.fl[cellId], + r: data.r[cellId], + conf: data.conf[cellId], + biome: data.biome[cellId], + s: data.s[cellId], + pop: data.pop[cellId], + culture: data.culture[cellId], + burg: data.burg[cellId], + routes: data.routes[cellId], + state: data.state[cellId], + religion: data.religion[cellId], + province: data.province[cellId] })), vertices: Array.from(pack.vertices.p).map((_, vertexId) => ({ i: vertexId, diff --git a/modules/io/load.js b/modules/io/load.js index 239507a44..ea5b8a5c8 100644 --- a/modules/io/load.js +++ b/modules/io/load.js @@ -374,6 +374,7 @@ async function parseLoadedData(data, mapVersion) { pack.provinces = data[30] ? JSON.parse(data[30]) : [0]; pack.rivers = data[32] ? JSON.parse(data[32]) : []; pack.markers = data[35] ? JSON.parse(data[35]) : []; + pack.routes = data[37] ? JSON.parse(data[37]) : []; const cells = pack.cells; cells.biome = Uint8Array.from(data[16].split(",")); @@ -383,12 +384,13 @@ 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.route = Uint8Array.from(data[23].split(",")); + // data[23] for deprecated cells.road 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); // data[28] for deprecated cells.crossroad + cells.routes = data[36] ? JSON.parse(data[36]) : {}; if (data[31]) { const namesDL = data[31].split("/"); diff --git a/modules/io/save.js b/modules/io/save.js index 45bf6018d..a3a5cbead 100644 --- a/modules/io/save.js +++ b/modules/io/save.js @@ -97,6 +97,8 @@ function prepareMapData() { const provinces = JSON.stringify(pack.provinces); const rivers = JSON.stringify(pack.rivers); const markers = JSON.stringify(pack.markers); + const cellRoutes = JSON.stringify(pack.cells.routes); + const routes = JSON.stringify(pack.routes); // store name array only if not the same as default const defaultNB = Names.getNameBases(); @@ -135,7 +137,7 @@ function prepareMapData() { pack.cells.fl, pop, pack.cells.r, - pack.cells.route, + [], // deprecated pack.cells.road pack.cells.s, pack.cells.state, pack.cells.religion, @@ -147,7 +149,9 @@ function prepareMapData() { rivers, rulersString, fonts, - markers + markers, + cellRoutes, + routes ].join("\r\n"); return mapData; } diff --git a/modules/markers-generator.js b/modules/markers-generator.js index 585a91538..d08f8807b 100644 --- a/modules/markers-generator.js +++ b/modules/markers-generator.js @@ -27,7 +27,7 @@ window.Markers = (function () { {type: "water-sources", icon: "💧", min: 1, each: 1000, multiplier: 1, list: listWaterSources, add: addWaterSource}, {type: "mines", icon: "⛏️", dx: 48, px: 13, min: 1, each: 15, multiplier: 1, list: listMines, add: addMine}, {type: "bridges", icon: "🌉", px: 14, min: 1, each: 5, multiplier: 1, list: listBridges, add: addBridge}, - {type: "inns", icon: "🍻", px: 14, min: 1, each: 100, multiplier: 1, list: listInns, add: addInn}, + {type: "inns", icon: "🍻", px: 14, min: 1, each: 10, multiplier: 1, list: listInns, add: addInn}, {type: "lighthouses", icon: "🚨", px: 14, min: 1, each: 2, multiplier: 1, list: listLighthouses, add: addLighthouse}, {type: "waterfalls", icon: "⟱", dy: 54, px: 16, min: 1, each: 5, multiplier: 1, list: listWaterfalls, add: addWaterfall}, {type: "battlefields", icon: "⚔️", dy: 52, min: 50, each: 700, multiplier: 1, list: listBattlefields, add: addBattlefield}, @@ -279,7 +279,8 @@ window.Markers = (function () { } function listInns({cells}) { - return cells.i.filter(i => !occupied[i] && cells.h[i] >= 20 && cells.route[i] === 1 && cells.pop[i] > 10); + const crossRoads = cells.i.filter(i => !occupied[i] && cells.pop[i] > 5 && Routes.isCrossroad(i)); + return crossRoads; } function addInn(id, cell) { @@ -542,7 +543,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.route[c]) + i => !occupied[i] && cells.harbor[i] > 6 && cells.c[i].some(c => cells.h[c] < 20 && Routes.isConnected(c)) ); } @@ -642,7 +643,7 @@ window.Markers = (function () { function listSeaMonsters({cells, features}) { return cells.i.filter( - i => !occupied[i] && cells.h[i] < 20 && cells.route[i] && features[cells.f[i]].type === "ocean" + i => !occupied[i] && cells.h[i] < 20 && Routes.isConnected(i) && features[cells.f[i]].type === "ocean" ); } @@ -792,7 +793,7 @@ window.Markers = (function () { cells.religion[i] && cells.biome[i] === 1 && cells.pop[i] > 1 && - cells.route[i] + Routes.isConnected(i) ); } @@ -807,7 +808,7 @@ window.Markers = (function () { } function listBrigands({cells}) { - return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.route[i] === 1); + return cells.i.filter(i => !occupied[i] && cells.culture[i] && Routes.hasRoad(i)); } function addBrigands(id, cell) { @@ -867,7 +868,7 @@ window.Markers = (function () { // Pirates spawn on sea routes function listPirates({cells}) { - return cells.i.filter(i => !occupied[i] && cells.h[i] < 20 && cells.route[i]); + return cells.i.filter(i => !occupied[i] && cells.h[i] < 20 && Routes.isConnected(i)); } function addPirates(id, cell) { @@ -961,7 +962,7 @@ window.Markers = (function () { } function listCircuses({cells}) { - return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.h[i] >= 20 && pack.cells.route[i]); + return cells.i.filter(i => !occupied[i] && cells.culture[i] && cells.h[i] >= 20 && Routes.isConnected(i)); } function addCircuse(id, cell) { @@ -1254,16 +1255,16 @@ window.Markers = (function () { const name = `${toponym} ${type}`; const legend = ra([ - "A foreboding necropolis shrouded in perpetual darkness, where eerie whispers echo through the winding corridors and spectral guardians stand watch over the tombs of long-forgotten souls", - "A towering necropolis adorned with macabre sculptures and guarded by formidable undead sentinels. Its ancient halls house the remains of fallen heroes, entombed alongside their cherished relics", - "This ethereal necropolis seems suspended between the realms of the living and the dead. Wisps of mist dance around the tombstones, while haunting melodies linger in the air, commemorating the departed", - "Rising from the desolate landscape, this sinister necropolis is a testament to necromantic power. Its skeletal spires cast ominous shadows, concealing forbidden knowledge and arcane secrets", - "An eerie necropolis where nature intertwines with death. Overgrown tombstones are entwined by thorny vines, and mournful spirits wander among the fading petals of once-vibrant flowers", - "A labyrinthine necropolis where each step echoes with haunting murmurs. The walls are adorned with ancient runes, and restless spirits guide or hinder those who dare to delve into its depths", - "This cursed necropolis is veiled in perpetual twilight, perpetuating a sense of impending doom. Dark enchantments shroud the tombs, and the moans of anguished souls resound through its crumbling halls", - "A sprawling necropolis built within a labyrinthine network of catacombs. Its halls are lined with countless alcoves, each housing the remains of the departed, while the distant sound of rattling bones fills the air", - "A desolate necropolis where an eerie stillness reigns. Time seems frozen amidst the decaying mausoleums, and the silence is broken only by the whispers of the wind and the rustle of tattered banners", - "A foreboding necropolis perched atop a jagged cliff, overlooking a desolate wasteland. Its towering walls harbor restless spirits, and the imposing gates bear the marks of countless battles and ancient curses" + "A foreboding necropolis shrouded in perpetual darkness, where eerie whispers echo through the winding corridors and spectral guardians stand watch over the tombs of long-forgotten souls.", + "A towering necropolis adorned with macabre sculptures and guarded by formidable undead sentinels. Its ancient halls house the remains of fallen heroes, entombed alongside their cherished relics.", + "This ethereal necropolis seems suspended between the realms of the living and the dead. Wisps of mist dance around the tombstones, while haunting melodies linger in the air, commemorating the departed.", + "Rising from the desolate landscape, this sinister necropolis is a testament to necromantic power. Its skeletal spires cast ominous shadows, concealing forbidden knowledge and arcane secrets.", + "An eerie necropolis where nature intertwines with death. Overgrown tombstones are entwined by thorny vines, and mournful spirits wander among the fading petals of once-vibrant flowers.", + "A labyrinthine necropolis where each step echoes with haunting murmurs. The walls are adorned with ancient runes, and restless spirits guide or hinder those who dare to delve into its depths.", + "This cursed necropolis is veiled in perpetual twilight, perpetuating a sense of impending doom. Dark enchantments shroud the tombs, and the moans of anguished souls resound through its crumbling halls.", + "A sprawling necropolis built within a labyrinthine network of catacombs. Its halls are lined with countless alcoves, each housing the remains of the departed, while the distant sound of rattling bones fills the air.", + "A desolate necropolis where an eerie stillness reigns. Time seems frozen amidst the decaying mausoleums, and the silence is broken only by the whispers of the wind and the rustle of tattered banners.", + "A foreboding necropolis perched atop a jagged cliff, overlooking a desolate wasteland. Its towering walls harbor restless spirits, and the imposing gates bear the marks of countless battles and ancient curses." ]); notes.push({id, name, legend}); diff --git a/modules/religions-generator.js b/modules/religions-generator.js index 238fcddfe..ca60466b9 100644 --- a/modules/religions-generator.js +++ b/modules/religions-generator.js @@ -692,7 +692,7 @@ window.Religions = (function () { // growth algorithm to assign cells to religions function expandReligions(religions) { - const cells = pack.cells; + const {cells, routes} = pack; const religionIds = spreadFolkReligions(religions); const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); @@ -700,8 +700,6 @@ window.Religions = (function () { const maxExpansionCost = (cells.i.length / 20) * neutralInput.value; // limit cost for organized religions growth - const biomePassageCost = cellId => biomesData.cost[cells.biome[cellId]]; - religions .filter(r => r.i && !r.lock && r.type !== "Folk" && !r.removed) .forEach(r => { @@ -712,11 +710,6 @@ window.Religions = (function () { const religionsMap = new Map(religions.map(r => [r.i, r])); - 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) { const {e: cellId, p, r, s: state} = queue.dequeue(); const {culture, expansion, expansionism} = religionsMap.get(r); @@ -728,7 +721,7 @@ window.Religions = (function () { const cultureCost = culture !== cells.culture[nextCell] ? 10 : 0; const stateCost = state !== cells.state[nextCell] ? 10 : 0; - const passageCost = getPassageCost(nextCell); + const passageCost = getPassageCost(cellId, nextCell); const cellCost = cultureCost + stateCost + passageCost; const totalCost = p + 10 + cellCost / expansionism; @@ -745,11 +738,18 @@ window.Religions = (function () { return religionIds; - function getPassageCost(cellId) { - if (isWater(cellId)) return isSeaRoute ? 50 : 500; - if (isMainRoad(cellId)) return 1; - const biomeCost = biomePassageCost(cellId); - return isTrail(cellId) ? biomeCost / 1.5 : biomeCost; + function getPassageCost(cellId, nextCellId) { + const route = Routes.getRoute(cellId, nextCellId); + if (isWater(cellId)) return route ? 50 : 500; + + const biomePassageCost = biomesData.cost[cells.biome[nextCellId]]; + + if (route) { + if (route.group === "roads") return 1; + return biomePassageCost / 3; // trails and other routes + } + + return biomePassageCost; } } diff --git a/modules/routes-generator.js b/modules/routes-generator.js index 9dbd6679e..9611ba326 100644 --- a/modules/routes-generator.js +++ b/modules/routes-generator.js @@ -1,40 +1,14 @@ -// suggested data format - -// pack.cells.connectivity = { -// cellId1: { -// toCellId2: routeId2, -// toCellId3: routeId2, -// }, -// cellId2: { -// toCellId1: routeId2, -// toCellId3: routeId1, -// } -// } - -// pack.routes = [ -// {i, group: "roads", feature: featureId, cells: [cellId], points?: [[x, y], [x, y]]} -// ]; - window.Routes = (function () { - const ROUTES = { - MAIN_ROAD: 1, - TRAIL: 2, - SEA_ROUTE: 3 - }; - function generate() { - const {cells, burgs} = pack; - const cellRoutes = new Uint8Array(cells.h.length); - - const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(burgs); + const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(pack.burgs); const connections = new Map(); const mainRoads = generateMainRoads(); const trails = generateTrails(); const seaRoutes = generateSeaRoutes(); - cells.route = cellRoutes; pack.routes = combineRoutes(); + pack.cells.routes = buildLinks(pack.routes); function sortBurgsByFeature(burgs) { const burgsByFeature = {}; @@ -71,7 +45,7 @@ window.Routes = (function () { const segments = findPathSegments({isWater: false, connections, start, exit}); for (const segment of segments) { - addConnections(segment, ROUTES.MAIN_ROAD); + addConnections(segment); mainRoads.push({feature: Number(key), cells: segment}); } }); @@ -94,7 +68,7 @@ window.Routes = (function () { const segments = findPathSegments({isWater: false, connections, start, exit}); for (const segment of segments) { - addConnections(segment, ROUTES.TRAIL); + addConnections(segment); trails.push({feature: Number(key), cells: segment}); } }); @@ -117,7 +91,7 @@ window.Routes = (function () { const exit = featurePorts[toId].cell; const segments = findPathSegments({isWater: true, connections, start, exit}); for (const segment of segments) { - addConnections(segment, ROUTES.SEA_ROUTE); + addConnections(segment); seaRoutes.push({feature: Number(featureId), cells: segment}); } }); @@ -127,7 +101,7 @@ window.Routes = (function () { return seaRoutes; } - function addConnections(segment, routeTypeId) { + function addConnections(segment) { for (let i = 0; i < segment.length; i++) { const cellId = segment[i]; const nextCellId = segment[i + 1]; @@ -135,7 +109,6 @@ window.Routes = (function () { connections.set(`${cellId}-${nextCellId}`, true); connections.set(`${nextCellId}-${cellId}`, true); } - if (!cellRoutes[cellId]) cellRoutes[cellId] = routeTypeId; } } @@ -165,10 +138,29 @@ window.Routes = (function () { return routes; } + + function buildLinks(routes) { + const links = {}; + + for (const {cells, i: routeId} of routes) { + for (let i = 0; i < cells.length; i++) { + const cellId = cells[i]; + const nextCellId = cells[i + 1]; + if (nextCellId) { + if (!links[cellId]) links[cellId] = {}; + links[cellId][nextCellId] = routeId; + + if (!links[nextCellId]) links[nextCellId] = {}; + links[nextCellId][cellId] = routeId; + } + } + } + + return links; + } } const MIN_PASSABLE_SEA_TEMP = -4; - const TYPE_MODIFIERS = { "-1": 1, // coastline "-2": 1.8, // sea @@ -339,5 +331,36 @@ window.Routes = (function () { return edges; } - return {generate}; + // utility functions + function isConnected(cellId) { + const {routes} = pack.cells; + 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]; + return routeId === undefined ? null : pack.routes[routeId]; + } + + function hasRoad(cellId) { + const connections = pack.cells.routes[cellId]; + if (!connections) return false; + return Object.values(connections).some(routeId => pack.routes[routeId].group === "roads"); + } + + function isCrossroad(cellId) { + const connections = pack.cells.routes[cellId]; + if (!connections) return false; + return ( + Object.keys(connections).length > 3 || + Object.values(connections).filter(routeId => pack.routes[routeId].group === "roads").length > 2 + ); + } + + return {generate, isConnected, areConnected, getRoute, hasRoad, isCrossroad}; })(); diff --git a/modules/submap.js b/modules/submap.js index 6dfa6049e..975175e9f 100644 --- a/modules/submap.js +++ b/modules/submap.js @@ -145,7 +145,6 @@ window.Submap = (function () { cells.state = new Uint16Array(pn); cells.burg = new Uint16Array(pn); cells.religion = new Uint16Array(pn); - cells.route = new Uint8Array(pn); cells.province = new Uint16Array(pn); stage("Resampling culture, state and religion map."); diff --git a/modules/ui/editors.js b/modules/ui/editors.js index b304ce819..05359f15f 100644 --- a/modules/ui/editors.js +++ b/modules/ui/editors.js @@ -326,8 +326,7 @@ function createMfcgLink(burg) { const citadel = +burg.citadel; const urban_castle = +(citadel && each(2)(i)); - const hub = +cells.route[cell] === 1; - + const hub = Routes.isCrossroad(cell); const walls = +burg.walls; const plaza = +burg.plaza; const temple = +burg.temple; @@ -371,10 +370,12 @@ 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.route[c]).length; - if (roadsAround > 1) tags.push("highway"); - else if (roadsAround === 1) tags.push("dead end"); - else tags.push("isolated"); + const connections = pack.cells.routes[cell] || {}; + const roads = Object.values(connections).filter(routeId => { + const route = pack.routes[routeId]; + return route.group === "roads" || route.group === "trails"; + }).length; + tags.push(roads > 1 ? "highway" : roads === 1 ? "dead end" : "isolated"); const biome = cells.biome[cell]; const arableBiomes = cells.r[cell] ? [1, 2, 3, 4, 5, 6, 7, 8] : [5, 6, 7, 8]; diff --git a/modules/ui/heightmap-editor.js b/modules/ui/heightmap-editor.js index c99e4bddb..2cec7437c 100644 --- a/modules/ui/heightmap-editor.js +++ b/modules/ui/heightmap-editor.js @@ -282,7 +282,7 @@ function editHeightmap(options) { const l = grid.cells.i.length; const biome = new Uint8Array(l); const pop = new Uint16Array(l); - const route = new Uint8Array(l); + const routes = {}; const s = new Uint16Array(l); const burg = new Uint16Array(l); const state = new Uint16Array(l); @@ -300,7 +300,7 @@ function editHeightmap(options) { biome[g] = pack.cells.biome[i]; culture[g] = pack.cells.culture[i]; pop[g] = pack.cells.pop[i]; - route[g] = pack.cells.route[i]; + routes[g] = pack.cells.routes[i]; s[g] = pack.cells.s[i]; state[g] = pack.cells.state[i]; province[g] = pack.cells.province[i]; @@ -352,7 +352,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.route = new Uint8Array(n); + pack.cells.routes = {}; pack.cells.s = new Uint16Array(n); pack.cells.burg = new Uint16Array(n); pack.cells.state = new Uint16Array(n); @@ -387,7 +387,7 @@ function editHeightmap(options) { if (!isLand) continue; pack.cells.culture[i] = culture[g]; pack.cells.pop[i] = pop[g]; - pack.cells.route[i] = route[g]; + pack.cells.routes[i] = routes[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 d2d01c198..e083936c4 100644 --- a/modules/ui/measurers.js +++ b/modules/ui/measurers.js @@ -486,7 +486,7 @@ class RouteOpisometer extends Measurer { const cells = pack.cells; const c = findCell(mousePoint[0], mousePoint[1]); - if (!cells.route[c] && !d3.event.sourceEvent.shiftKey) return; + if (!Routes.isConnected(c) && !d3.event.sourceEvent.shiftKey) return; context.trackCell(c, rigth); }); diff --git a/modules/ui/units-editor.js b/modules/ui/units-editor.js index 6f0332bf8..86079026a 100644 --- a/modules/ui/units-editor.js +++ b/modules/ui/units-editor.js @@ -179,13 +179,15 @@ function editUnits() { tip("Draw a curve along routes to measure length. Hold Shift to measure away from roads.", true); unitsBottom.querySelectorAll(".pressed").forEach(button => button.classList.remove("pressed")); this.classList.add("pressed"); + viewbox.style("cursor", "crosshair").call( d3.drag().on("start", function () { const cells = pack.cells; const burgs = pack.burgs; const point = d3.mouse(this); const c = findCell(point[0], point[1]); - if (cells.route[c] || d3.event.sourceEvent.shiftKey) { + + if (Routes.isConnected(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 +196,7 @@ function editUnits() { d3.event.on("drag", function () { const point = d3.mouse(this); const c = findCell(point[0], point[1]); - if (cells.route[c] || d3.event.sourceEvent.shiftKey) { + if (Routes.isConnected(c) || d3.event.sourceEvent.shiftKey) { routeOpisometer.trackCell(c, true); } }); From 681d97b2a78576ca8497ed7256424d7a1cc843b9 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sun, 28 Apr 2024 18:28:10 +0200 Subject: [PATCH 07/37] feat: routes - add routes to json export --- modules/dynamic/export-json.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/dynamic/export-json.js b/modules/dynamic/export-json.js index 88cead8cb..df8db747b 100644 --- a/modules/dynamic/export-json.js +++ b/modules/dynamic/export-json.js @@ -52,7 +52,8 @@ function getMinimalDataJson() { provinces: pack.provinces, religions: pack.religions, rivers: pack.rivers, - markers: pack.markers + markers: pack.markers, + routes: pack.routes }; return JSON.stringify({info, settings, mapCoordinates, pack: packData, biomesData, notes, nameBases}); } @@ -167,7 +168,8 @@ function getPackCellsData() { provinces: pack.provinces, religions: pack.religions, rivers: pack.rivers, - markers: pack.markers + markers: pack.markers, + routes: pack.routes }; } From d6c01c89952108dd1f533cbc85c07e194e75edd3 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Mon, 29 Apr 2024 23:19:11 +0200 Subject: [PATCH 08/37] feat: edit routes - start --- modules/ui/editors.js | 2 +- modules/ui/routes-editor.js | 65 ++++++++++++++++++------------------- modules/ui/tools.js | 12 +++---- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/modules/ui/editors.js b/modules/ui/editors.js index 05359f15f..b8ba30921 100644 --- a/modules/ui/editors.js +++ b/modules/ui/editors.js @@ -22,7 +22,7 @@ function clicked() { if (grand.id === "emblems") editEmblem(); else if (parent.id === "rivers") editRiver(el.id); - else if (grand.id === "routes") editRoute(); + else if (grand.id === "routes") editRoute({node: el}); else if (el.tagName === "tspan" && grand.parentNode.parentNode.id === "labels") editLabel(); else if (grand.id === "burgLabels") editBurg(); else if (grand.id === "burgIcons") editBurg(); diff --git a/modules/ui/routes-editor.js b/modules/ui/routes-editor.js index ca52b0362..ad778f91f 100644 --- a/modules/ui/routes-editor.js +++ b/modules/ui/routes-editor.js @@ -1,10 +1,7 @@ "use strict"; -const CONTROL_POINST_DISTANCE = 10; - -function editRoute(onClick) { +function editRoute({node, mode}) { if (customization) return; - if (!onClick && elSelected && d3.event.target.id === elSelected.attr("id")) return; closeDialogs(".stable"); if (!layerIsOn("toggleRoutes")) toggleRoutes(); @@ -16,13 +13,12 @@ function editRoute(onClick) { }); debug.append("g").attr("id", "controlPoints"); - const node = onClick ? elSelected.node() : d3.event.target; - elSelected = d3.select(node).on("click", addInterimControlPoint); + d3.select(node).on("click", addInterimControlPoint); drawControlPoints(node); selectRouteGroup(node); viewbox.on("touchmove mousemove", showEditorTips); - if (onClick) toggleRouteCreationMode(); + if (mode === "onclick") toggleRouteCreationMode(); if (modules.editRoute) return; modules.editRoute = true; @@ -45,12 +41,13 @@ function editRoute(onClick) { function showEditorTips() { showMainTip(); if (routeNew.classList.contains("pressed")) return; - if (d3.event.target.id === elSelected.attr("id")) tip("Click to add a control point"); + if (d3.event.target.id === node.getAttribute("id")) tip("Click to add a control point"); else if (d3.event.target.parentNode.id === "controlPoints") tip("Drag to move, click to delete the control point"); } function drawControlPoints(node) { const totalLength = node.getTotalLength(); + const CONTROL_POINST_DISTANCE = 10; const increment = totalLength / Math.ceil(totalLength / CONTROL_POINST_DISTANCE); for (let i = 0; i <= totalLength; i += increment) { const point = node.getPointAtLength(i); @@ -96,16 +93,15 @@ function editRoute(onClick) { points.push([this.getAttribute("cx"), this.getAttribute("cy")]); }); - elSelected.attr("d", round(lineGen(points))); - const l = elSelected.node().getTotalLength(); - routeLength.innerHTML = rn(l * distanceScaleInput.value) + " " + distanceUnitInput.value; + node.setAttribute("d", round(lineGen(points))); + routeLength.innerHTML = rn(node.getTotalLength() * distanceScaleInput.value) + " " + distanceUnitInput.value; - if (modules.elevation) showEPForRoute(elSelected.node()); + if (modules.elevation) showEPForRoute(node); } function showElevationProfile() { modules.elevation = true; - showEPForRoute(elSelected.node()); + showEPForRoute(node); } function showGroupSection() { @@ -132,7 +128,7 @@ function editRoute(onClick) { } function changeRouteGroup() { - document.getElementById(this.value).appendChild(elSelected.node()); + document.getElementById(this.value).appendChild(node); } function toggleNewGroupInput() { @@ -166,7 +162,7 @@ function editRoute(onClick) { return; } // just rename if only 1 element left - const oldGroup = elSelected.node().parentNode; + const oldGroup = node.parentNode; const basic = ["roads", "trails", "searoutes"].includes(oldGroup.id); if (!basic && oldGroup.childElementCount === 1) { document.getElementById("routeGroup").selectedOptions[0].remove(); @@ -177,20 +173,20 @@ function editRoute(onClick) { return; } - const newGroup = elSelected.node().parentNode.cloneNode(false); + const newGroup = node.parentNode.cloneNode(false); document.getElementById("routes").appendChild(newGroup); newGroup.id = group; document.getElementById("routeGroup").options.add(new Option(group, group, false, true)); - document.getElementById(group).appendChild(elSelected.node()); + document.getElementById(group).appendChild(node); toggleNewGroupInput(); document.getElementById("routeGroupName").value = ""; } function removeRouteGroup() { - const group = elSelected.node().parentNode.id; + const group = node.parentNode.id; const basic = ["roads", "trails", "searoutes"].includes(group); - const count = elSelected.node().parentNode.childElementCount; + const count = node.parentNode.childElementCount; alertMessage.innerHTML = /* html */ `Are you sure you want to remove ${ basic ? "all elements in the group" : "the entire route group" }?

Routes to be @@ -218,7 +214,7 @@ function editRoute(onClick) { } function editGroupStyle() { - const g = elSelected.node().parentNode.id; + const g = node.parentNode.id; editStyle("routes", g); } @@ -237,12 +233,13 @@ function editRoute(onClick) { function splitRoute(clicked) { lineGen.curve(d3.curveCatmullRom.alpha(0.1)); - const group = d3.select(elSelected.node().parentNode); + const group = d3.select(node.parentNode); routeSplit.classList.remove("pressed"); - const points1 = [], - points2 = []; + const points1 = []; + const points2 = []; let points = points1; + debug .select("#controlPoints") .selectAll("circle") @@ -255,11 +252,11 @@ function editRoute(onClick) { this.remove(); }); - elSelected.attr("d", round(lineGen(points1))); + node.setAttribute("d", round(lineGen(points1))); const id = getNextId("route"); group.append("path").attr("id", id).attr("d", lineGen(points2)); debug.select("#controlPoints").selectAll("circle").remove(); - drawControlPoints(elSelected.node()); + drawControlPoints(node); } function toggleRouteCreationMode() { @@ -268,21 +265,22 @@ function editRoute(onClick) { if (document.getElementById("routeNew").classList.contains("pressed")) { tip("Click on map to add control points", true); viewbox.on("click", addPointOnClick).style("cursor", "crosshair"); - elSelected.on("click", null); + d3.select(node).on("click", null); } else { clearMainTip(); viewbox.on("click", clicked).style("cursor", "default"); - elSelected.on("click", addInterimControlPoint).attr("data-new", null); + d3.select(node).on("click", addInterimControlPoint).attr("data-new", null); } } function addPointOnClick() { // create new route - if (!elSelected.attr("data-new")) { + if (!node.dataset.new) { debug.select("#controlPoints").selectAll("circle").remove(); - const parent = elSelected.node().parentNode; + const parent = node.parentNode; const id = getNextId("route"); - elSelected = d3.select(parent).append("path").attr("id", id).attr("data-new", 1); + const newRoute = d3.select(parent).append("path").attr("id", id).attr("data-new", 1); + node = newRoute.node(); } addControlPoint(d3.mouse(this)); @@ -290,7 +288,7 @@ function editRoute(onClick) { } function editRouteLegend() { - const id = elSelected.attr("id"); + const id = node.getAttribute("id"); editNotes(id, id); } @@ -302,7 +300,7 @@ function editRoute(onClick) { buttons: { Remove: function () { $(this).dialog("close"); - elSelected.remove(); + node.remove(); $("#routeEditor").dialog("close"); }, Cancel: function () { @@ -313,7 +311,8 @@ function editRoute(onClick) { } function closeRoutesEditor() { - elSelected.attr("data-new", null).on("click", null); + node.data.new = null; + d3.select(node).on("click", null); clearMainTip(); routeSplit.classList.remove("pressed"); routeNew.classList.remove("pressed"); diff --git a/modules/ui/tools.js b/modules/ui/tools.js index d743246e5..16a50b69d 100644 --- a/modules/ui/tools.js +++ b/modules/ui/tools.js @@ -791,15 +791,15 @@ function toggleAddRoute() { function addRouteOnClick() { unpressClickToAddButton(); - const point = d3.mouse(this); - const id = getNextId("route"); - elSelected = routes + const [x, y] = d3.mouse(this); + + const newRoute = routes .select("g") .append("path") - .attr("id", id) + .attr("id", getNextId("route")) .attr("data-new", 1) - .attr("d", `M${point[0]},${point[1]}`); - editRoute(true); + .attr("d", `M${x},${y}`); + editRoute({node: newRoute.node(), mode: "onclick"}); } function toggleAddMarker() { From 68b4cfd37029c997a25e55cd29a722b5ece302e8 Mon Sep 17 00:00:00 2001 From: Azgaar Date: Sat, 4 May 2024 12:04:45 +0200 Subject: [PATCH 09/37] feat: edit routes - main --- index.css | 9 +- index.html | 72 +++--- libs/flatqueue.js | 57 +++++ modules/io/export.js | 24 +- modules/ui/editors.js | 2 +- modules/ui/layers.js | 72 +++--- modules/ui/rivers-editor.js | 5 +- modules/ui/routes-editor.js | 451 +++++++++++++++++------------------- modules/ui/tools.js | 10 +- 9 files changed, 373 insertions(+), 329 deletions(-) create mode 100644 libs/flatqueue.js diff --git a/index.css b/index.css index d5c5ef341..ca7f2a323 100644 --- a/index.css +++ b/index.css @@ -258,7 +258,6 @@ i.icon-lock { cursor: pointer; } -#routeEditor > *, #labelEditor div { display: inline-block; } @@ -1610,6 +1609,7 @@ div.states > .riverType { #burgBody > div > div, #riverBody > div, +#routeBody > div, #lakeBody > div { padding: 0.1em; } @@ -1617,6 +1617,7 @@ div.states > .riverType { #riverBody div.label, #riverBody input, #riverBody select, +#routeBody div.label, #lakeBody div.label, #lakeBody input, #lakeBody select { @@ -1624,6 +1625,12 @@ div.states > .riverType { width: 7em; } +#routeBody input, +#routeBody select { + display: inline-block; + width: 10em; +} + #stateNameEditor div.label, #provinceNameEditor div.label, #regimentBody div.label, diff --git a/index.html b/index.html index 94a12ef22..25a6d45b9 100644 --- a/index.html +++ b/index.html @@ -2897,35 +2897,51 @@