feat: routes generation

This commit is contained in:
Azgaar 2024-03-24 20:10:11 +01:00
parent af927ed345
commit 6776e5b867
19 changed files with 607 additions and 295 deletions

View file

@ -6146,7 +6146,7 @@
Data to be copied: heightmap, biomes, religions, population, precipitation, cultures, states, provinces, Data to be copied: heightmap, biomes, religions, population, precipitation, cultures, states, provinces,
military regiments military regiments
</p> </p>
<p>Data to be regenerated: zones, roads, rivers</p> <p>Data to be regenerated: zones, routes, rivers</p>
<p>Burgs may be remapped incorrectly, manual change is required</p> <p>Burgs may be remapped incorrectly, manual change is required</p>
<p>Keep data for:</p> <p>Keep data for:</p>
@ -8009,10 +8009,12 @@
<script src="libs/priority-queue.min.js"></script> <script src="libs/priority-queue.min.js"></script>
<script src="libs/delaunator.min.js"></script> <script src="libs/delaunator.min.js"></script>
<script src="libs/indexedDB.js?v=1.91.01"></script> <script src="libs/indexedDB.js?v=1.91.01"></script>
<script src="https://cdn.jsdelivr.net/npm/flatqueue"></script>
<script src="utils/shorthands.js"></script> <script src="utils/shorthands.js"></script>
<script src="utils/commonUtils.js?v=1.89.29"></script> <script src="utils/commonUtils.js?v=1.89.29"></script>
<script src="utils/arrayUtils.js"></script> <script src="utils/arrayUtils.js"></script>
<script src="utils/functionUtils.js"></script>
<script src="utils/colorUtils.js"></script> <script src="utils/colorUtils.js"></script>
<script src="utils/graphUtils.js?v=1.96.00"></script> <script src="utils/graphUtils.js?v=1.96.00"></script>
<script src="utils/nodeUtils.js"></script> <script src="utils/nodeUtils.js"></script>
@ -8035,7 +8037,7 @@
<script src="modules/cultures-generator.js?v=1.96.05"></script> <script src="modules/cultures-generator.js?v=1.96.05"></script>
<script src="modules/renderers/state-labels.js?v=1.96.04"></script> <script src="modules/renderers/state-labels.js?v=1.96.04"></script>
<script src="modules/burgs-and-states.js?v=1.97.00"></script> <script src="modules/burgs-and-states.js?v=1.97.00"></script>
<script src="modules/routes-generator.js"></script> <script src="modules/routes-generator.js?v=1.98.00"></script>
<script src="modules/religions-generator.js?v=1.93.08"></script> <script src="modules/religions-generator.js?v=1.93.08"></script>
<script src="modules/military-generator.js?v=1.96.01"></script> <script src="modules/military-generator.js?v=1.96.01"></script>
<script src="modules/markers-generator.js?v=1.93.04"></script> <script src="modules/markers-generator.js?v=1.93.04"></script>

11
main.js
View file

@ -644,6 +644,7 @@ async function generate(options) {
Cultures.generate(); Cultures.generate();
Cultures.expand(); Cultures.expand();
BurgsAndStates.generate(); BurgsAndStates.generate();
Routes.generate();
Religions.generate(); Religions.generate();
BurgsAndStates.defineStateForms(); BurgsAndStates.defineStateForms();
BurgsAndStates.generateProvinces(); BurgsAndStates.generateProvinces();
@ -1652,8 +1653,8 @@ function addZones(number = 1) {
used[next.e] = 1; used[next.e] = 1;
cells.c[next.e].forEach(function (e) { cells.c[next.e].forEach(function (e) {
const r = cells.road[next.e]; const r = cells.route[next.e];
const c = r ? Math.max(10 - r, 1) : 100; const c = r ? 5 : 100;
const p = next.p + c; const p = next.p + c;
if (p > power) return; if (p > power) return;
@ -1780,10 +1781,10 @@ function addZones(number = 1) {
} }
function addAvalanche() { function addAvalanche() {
const roads = cells.i.filter(i => !used[i] && cells.road[i] && cells.h[i] >= 70); const routes = cells.i.filter(i => !used[i] && cells.route[i] && cells.h[i] >= 70);
if (!roads.length) return; if (!routes.length) return;
const cell = +ra(roads); const cell = +ra(routes);
const cellsArray = [], const cellsArray = [],
queue = [cell], queue = [cell],
power = rand(3, 15); power = rand(3, 15);

View file

@ -6,27 +6,20 @@ window.BurgsAndStates = (function () {
const n = cells.i.length; const n = cells.i.length;
cells.burg = new Uint16Array(n); // cell burg 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()); const burgs = (pack.burgs = placeCapitals());
pack.states = createStates(); pack.states = createStates();
const capitalRoutes = Routes.getRoads();
placeTowns(); placeTowns();
expandStates(); expandStates();
normalizeStates(); normalizeStates();
const townRoutes = Routes.getTrails();
specifyBurgs(); specifyBurgs();
const oceanRoutes = Routes.getSearoutes();
collectStatistics(); collectStatistics();
assignColors(); assignColors();
generateCampaigns(); generateCampaigns();
generateDiplomacy(); generateDiplomacy();
Routes.draw(capitalRoutes, townRoutes, oceanRoutes);
drawBurgs(); drawBurgs();
function placeCapitals() { function placeCapitals() {
@ -167,7 +160,6 @@ window.BurgsAndStates = (function () {
const specifyBurgs = function () { const specifyBurgs = function () {
TIME && console.time("specifyBurgs"); TIME && console.time("specifyBurgs");
const cells = pack.cells, const cells = pack.cells,
vertices = pack.vertices,
features = pack.features, features = pack.features,
temp = grid.cells.temp; temp = grid.cells.temp;
@ -185,7 +177,7 @@ window.BurgsAndStates = (function () {
} else b.port = 0; } else b.port = 0;
// define burg population (keep urbanization at about 10% rate) // 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.capital) b.population = rn(b.population * 1.3, 3); // increase capital population
if (b.port) { if (b.port) {

View file

@ -122,8 +122,7 @@ function getPackCellsData() {
pop: Array.from(pack.cells.pop), pop: Array.from(pack.cells.pop),
culture: Array.from(pack.cells.culture), culture: Array.from(pack.cells.culture),
burg: Array.from(pack.cells.burg), burg: Array.from(pack.cells.burg),
road: Array.from(pack.cells.road), route: Array.from(pack.cells.route),
crossroad: Array.from(pack.cells.crossroad),
state: Array.from(pack.cells.state), state: Array.from(pack.cells.state),
religion: Array.from(pack.cells.religion), religion: Array.from(pack.cells.religion),
province: Array.from(pack.cells.province) province: Array.from(pack.cells.province)
@ -150,8 +149,7 @@ function getPackCellsData() {
pop: dataArrays.pop[cellId], pop: dataArrays.pop[cellId],
culture: dataArrays.culture[cellId], culture: dataArrays.culture[cellId],
burg: dataArrays.burg[cellId], burg: dataArrays.burg[cellId],
road: dataArrays.road[cellId], route: dataArrays.route[cellId],
crossroad: dataArrays.crossroad[cellId],
state: dataArrays.state[cellId], state: dataArrays.state[cellId],
religion: dataArrays.religion[cellId], religion: dataArrays.religion[cellId],
province: dataArrays.province[cellId] province: dataArrays.province[cellId]

View file

@ -1,5 +1,3 @@
import {rollups} from "../../../utils/functionUtils.js";
const entitiesMap = { const entitiesMap = {
states: { states: {
label: "State", label: "State",

View file

@ -383,12 +383,12 @@ async function parseLoadedData(data, mapVersion) {
cells.fl = Uint16Array.from(data[20].split(",")); cells.fl = Uint16Array.from(data[20].split(","));
cells.pop = Float32Array.from(data[21].split(",")); cells.pop = Float32Array.from(data[21].split(","));
cells.r = Uint16Array.from(data[22].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.s = Uint16Array.from(data[24].split(","));
cells.state = Uint16Array.from(data[25].split(",")); cells.state = Uint16Array.from(data[25].split(","));
cells.religion = data[26] ? Uint16Array.from(data[26].split(",")) : new Uint16Array(cells.i.length); 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.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]) { if (data[31]) {
const namesDL = data[31].split("/"); const namesDL = data[31].split("/");

View file

@ -135,12 +135,12 @@ function prepareMapData() {
pack.cells.fl, pack.cells.fl,
pop, pop,
pack.cells.r, pack.cells.r,
pack.cells.road, pack.cells.route,
pack.cells.s, pack.cells.s,
pack.cells.state, pack.cells.state,
pack.cells.religion, pack.cells.religion,
pack.cells.province, pack.cells.province,
pack.cells.crossroad, [], // deprecated pack.cells.crossroad
religions, religions,
provinces, provinces,
namesData, namesData,

View file

@ -279,7 +279,7 @@ window.Markers = (function () {
} }
function listInns({cells}) { 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) { function addInn(id, cell) {
@ -542,7 +542,7 @@ window.Markers = (function () {
function listLighthouses({cells}) { function listLighthouses({cells}) {
return cells.i.filter( 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}) { function listSeaMonsters({cells, features}) {
return cells.i.filter( 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.religion[i] &&
cells.biome[i] === 1 && cells.biome[i] === 1 &&
cells.pop[i] > 1 && cells.pop[i] > 1 &&
cells.road[i] cells.route[i]
); );
} }
@ -807,7 +807,7 @@ window.Markers = (function () {
} }
function listBrigands({cells}) { 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) { function addBrigands(id, cell) {
@ -867,7 +867,7 @@ window.Markers = (function () {
// Pirates spawn on sea routes // Pirates spawn on sea routes
function listPirates({cells}) { 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) { function addPirates(id, cell) {
@ -961,7 +961,7 @@ window.Markers = (function () {
} }
function listCircuses({cells}) { 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) { function addCircuse(id, cell) {

View file

@ -712,9 +712,9 @@ window.Religions = (function () {
const religionsMap = new Map(religions.map(r => [r.i, r])); const religionsMap = new Map(religions.map(r => [r.i, r]));
const isMainRoad = cellId => cells.road[cellId] - cells.crossroad[cellId] > 4; const isMainRoad = cellId => cells.route[cellId] === 1;
const isTrail = cellId => cells.h[cellId] > 19 && cells.road[cellId] - cells.crossroad[cellId] === 1; const isTrail = cellId => cells.route[cellId] === 2;
const isSeaRoute = cellId => cells.h[cellId] < 20 && cells.road[cellId]; const isSeaRoute = cellId => cells.route[cellId] === 3;
const isWater = cellId => cells.h[cellId] < 20; const isWater = cellId => cells.h[cellId] < 20;
while (queue.length) { while (queue.length) {

View file

@ -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) => `<path id="${type}${i}" d="${getPath(path)}" />`).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];
}
})();

View file

@ -1,269 +1,320 @@
window.Routes = (function () { window.Routes = (function () {
const getRoads = function () { const ROUTES = {
TIME && console.time("generateMainRoads"); MAIN_ROAD: 1,
const cells = pack.cells; TRAIL: 2,
const burgs = pack.burgs.filter(b => b.i && !b.removed); SEA_ROUTE: 3
const capitals = burgs.filter(b => b.capital).sort((a, b) => a.population - b.population);
if (capitals.length < 2) return []; // not enough capitals to build main roads
const paths = []; // array to store path segments
for (const b of capitals) {
const connect = capitals.filter(c => c.feature === b.feature && c !== b);
for (const t of connect) {
const [from, exit] = findLandPath(b.cell, t.cell, true);
const segments = restorePath(b.cell, exit, "main", from);
segments.forEach(s => paths.push(s));
}
}
cells.i.forEach(i => (cells.s[i] += cells.road[i] / 2)); // add roads to suitability score
TIME && console.timeEnd("generateMainRoads");
return paths;
}; };
const getTrails = function () { function generate() {
TIME && console.time("generateTrails");
const cells = pack.cells;
const burgs = pack.burgs.filter(b => b.i && !b.removed);
if (burgs.length < 2) return []; // not enough burgs to build trails
let paths = []; // array to store path segments
for (const f of pack.features.filter(f => f.land)) {
const isle = burgs.filter(b => b.feature === f.i); // burgs on island
if (isle.length < 2) continue;
isle.forEach(function (b, i) {
let path = [];
if (!i) {
// build trail from the first burg on island
// to the farthest one on the same island or the closest road
const farthest = d3.scan(isle, (a, c) => (c.y - b.y) ** 2 + (c.x - b.x) ** 2 - ((a.y - b.y) ** 2 + (a.x - b.x) ** 2));
const to = isle[farthest].cell;
if (cells.road[to]) return;
const [from, exit] = findLandPath(b.cell, to, true);
path = restorePath(b.cell, exit, "small", from);
} else {
// build trail from all other burgs to the closest road on the same island
if (cells.road[b.cell]) return;
const [from, exit] = findLandPath(b.cell, null, true);
if (exit === null) return;
path = restorePath(b.cell, exit, "small", from);
}
if (path) paths = paths.concat(path);
});
}
TIME && console.timeEnd("generateTrails");
return paths;
};
const getSearoutes = function () {
TIME && console.time("generateSearoutes");
const {cells, burgs, features} = pack;
const allPorts = burgs.filter(b => b.port > 0 && !b.removed);
if (!allPorts.length) return [];
const bodies = new Set(allPorts.map(b => b.port)); // water features with ports
let paths = []; // array to store path segments
const connected = []; // store cell id of connected burgs
bodies.forEach(f => {
const ports = allPorts.filter(b => b.port === f); // all ports on the same feature
if (!ports.length) return;
if (features[f]?.border) addOverseaRoute(f, ports[0]);
// get inner-map routes
for (let s = 0; s < ports.length; s++) {
const source = ports[s].cell;
if (connected[source]) continue;
for (let t = s + 1; t < ports.length; t++) {
const target = ports[t].cell;
if (connected[target]) continue;
const [from, exit, passable] = findOceanPath(target, source, true);
if (!passable) continue;
const path = restorePath(target, exit, "ocean", from);
paths = paths.concat(path);
connected[source] = 1;
connected[target] = 1;
}
}
});
function addOverseaRoute(f, port) {
const {x, y, cell: source} = port;
const dist = p => Math.abs(p[0] - x) + Math.abs(p[1] - y);
const [x1, y1] = [
[0, y],
[x, 0],
[graphWidth, y],
[x, graphHeight]
].sort((a, b) => dist(a) - dist(b))[0];
const target = findCell(x1, y1);
if (cells.f[target] === f && cells.h[target] < 20) {
const [from, exit, passable] = findOceanPath(target, source, true);
if (passable) {
const path = restorePath(target, exit, "ocean", from);
paths = paths.concat(path);
last(path).push([x1, y1]);
}
}
}
TIME && console.timeEnd("generateSearoutes");
return paths;
};
const draw = function (main, small, water) {
TIME && console.time("drawRoutes");
const {cells, burgs} = pack; const {cells, burgs} = pack;
const {burg, p} = cells;
const getBurgCoords = b => [burgs[b].x, burgs[b].y]; const cellRoutes = new Uint8Array(cells.h.length);
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) => `<path id="${type}${i}" d="${getPath(path)}" />`).join("");
lineGen.curve(d3.curveCatmullRom.alpha(0.1)); const {capitalsByFeature, burgsByFeature, portsByFeature} = sortBurgsByFeature(burgs);
roads.html(getPathsHTML(main, "road")); const connections = new Map();
trails.html(getPathsHTML(small, "trail"));
lineGen.curve(d3.curveBundle.beta(1)); const mainRoads = generateMainRoads();
searoutes.html(getPathsHTML(water, "searoute")); const trails = generateTrails();
const seaRoutes = generateSeaRoutes();
TIME && console.timeEnd("drawRoutes"); cells.route = cellRoutes;
}; pack.routes = combineRoutes();
const regenerate = function () { function sortBurgsByFeature(burgs) {
routes.selectAll("path").remove(); const burgsByFeature = {};
pack.cells.road = new Uint16Array(pack.cells.i.length); const capitalsByFeature = {};
pack.cells.crossroad = new Uint16Array(pack.cells.i.length); const portsByFeature = {};
const main = getRoads();
const small = getTrails();
const water = getSearoutes();
draw(main, small, water);
};
return {getRoads, getTrails, getSearoutes, draw, regenerate}; const addBurg = (object, feature, burg) => {
if (!object[feature]) object[feature] = [];
object[feature].push(burg);
};
// Find a land path to a specific cell (exit), to a closest road (toRoad), or to all reachable cells (null, null) for (const burg of burgs) {
function findLandPath(start, exit = null, toRoad = null) { if (burg.i && !burg.removed) {
const cells = pack.cells; const {feature, capital, port} = burg;
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); addBurg(burgsByFeature, feature, burg);
const cost = [], if (capital) addBurg(capitalsByFeature, feature, burg);
from = []; if (port) addBurg(portsByFeature, port, burg);
queue.queue({e: start, p: 0}); }
}
while (queue.length) { return {burgsByFeature, capitalsByFeature, portsByFeature};
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]) { function generateMainRoads() {
if (cells.h[c] < 20) continue; // ignore water cells TIME && console.time("generateMainRoads");
const stateChangeCost = cells.state && cells.state[c] !== cells.state[n] ? 400 : 0; // trails tend to lay within the same state const mainRoads = [];
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; for (const [key, featureCapitals] of Object.entries(capitalsByFeature)) {
from[c] = n; const points = featureCapitals.map(burg => [burg.x, burg.y]);
if (c === exit) return [from, exit]; const urquhartEdges = calculateUrquhartEdges(points);
cost[c] = totalCost; urquhartEdges.forEach(([fromId, toId]) => {
queue.queue({e: c, p: totalCost}); const start = featureCapitals[fromId].cell;
const exit = featureCapitals[toId].cell;
const segments = findPathSegments({isWater: false, cellRoutes, connections, start, exit});
for (const segment of segments) {
addConnections(segment, ROUTES.MAIN_ROAD);
mainRoads.push({feature: Number(key), cells: segment});
}
});
}
TIME && console.timeEnd("generateMainRoads");
return mainRoads;
}
function generateTrails() {
TIME && console.time("generateTrails");
const trails = [];
for (const [key, featureBurgs] of Object.entries(burgsByFeature)) {
const points = featureBurgs.map(burg => [burg.x, burg.y]);
const urquhartEdges = calculateUrquhartEdges(points);
urquhartEdges.forEach(([fromId, toId]) => {
const start = featureBurgs[fromId].cell;
const exit = featureBurgs[toId].cell;
const segments = findPathSegments({isWater: false, cellRoutes, connections, start, exit});
for (const segment of segments) {
addConnections(segment, ROUTES.TRAIL);
trails.push({feature: Number(key), cells: segment});
}
});
}
TIME && console.timeEnd("generateTrails");
return trails;
}
function generateSeaRoutes() {
TIME && console.time("generateSearoutes");
const mainRoads = [];
for (const [key, featurePorts] of Object.entries(portsByFeature)) {
const points = featurePorts.map(burg => [burg.x, burg.y]);
const urquhartEdges = calculateUrquhartEdges(points);
urquhartEdges.forEach(([fromId, toId]) => {
const start = featurePorts[fromId].cell;
const exit = featurePorts[toId].cell;
const segments = findPathSegments({isWater: true, cellRoutes, connections, start, exit});
for (const segment of segments) {
addConnections(segment, ROUTES.SEA_ROUTE);
mainRoads.push({feature: Number(key), cells: segment});
}
});
}
TIME && console.timeEnd("generateSearoutes");
return mainRoads;
}
function addConnections(segment, roadTypeId) {
for (let i = 0; i < segment.length; i++) {
const cellId = segment[i];
const nextCellId = segment[i + 1];
if (nextCellId) connections.set(`${cellId}-${nextCellId}`, true);
if (!cellRoutes[cellId]) cellRoutes[cellId] = roadTypeId;
} }
} }
return [from, exit];
function findPathSegments({isWater, cellRoutes, connections, start, exit}) {
const from = findPath(isWater, cellRoutes, start, exit, connections);
if (!from) return [];
const pathCells = restorePath(start, exit, from);
const segments = getRouteSegments(pathCells, connections);
return segments;
}
function combineRoutes() {
const routes = [];
for (const {feature, cells} of mainRoads) {
routes.push({i: routes.length, type: "road", feature, cells});
}
for (const {feature, cells} of trails) {
routes.push({i: routes.length, type: "trail", feature, cells});
}
for (const {feature, cells} of seaRoutes) {
routes.push({i: routes.length, type: "sea", feature, cells});
}
return routes;
}
} }
function restorePath(start, end, type, from) { function findPath(isWater, cellRoutes, start, exit, connections) {
const cells = pack.cells; const {temp} = grid.cells;
const path = []; // to store all segments; const {cells} = pack;
let segment = [],
current = end,
prev = end;
const score = type === "main" ? 5 : 1; // to increase road score at cell
if (type === "ocean" || !cells.road[prev]) segment.push(end); const from = [];
if (!cells.road[prev]) cells.road[prev] = score; const cost = [];
const queue = new FlatQueue();
queue.push(start, 0);
for (let i = 0, limit = 1000; i < limit; i++) { return isWater ? findWaterPath() : findLandPath();
if (!from[current]) break;
current = from[current];
if (cells.road[current]) { function findLandPath() {
while (queue.length) {
const priority = queue.peekValue();
const next = queue.pop();
for (const neibCellId of cells.c[next]) {
if (cells.h[neibCellId] < 20) continue; // ignore water cells
const habitability = biomesData.habitability[cells.biome[neibCellId]];
if (!habitability) continue; // inhabitable cells are not passable (eg. lava, glacier)
const distanceCost = dist2(cells.p[next], cells.p[neibCellId]);
const habitabilityModifier = 1 + Math.max(100 - habitability, 0) / 1000; // [1, 1.1];
const heightModifier = 1 + Math.max(cells.h[neibCellId] - 50, 0) / 50; // [1, 2];
const roadModifier = cellRoutes[neibCellId] ? 1 : 2;
const burgModifier = cells.burg[neibCellId] ? 1 : 2;
const cellsCost = distanceCost * habitabilityModifier * heightModifier * roadModifier * burgModifier;
const totalCost = priority + cellsCost;
if (from[neibCellId] || totalCost >= cost[neibCellId]) continue;
from[neibCellId] = next;
if (neibCellId === exit) return from;
cost[neibCellId] = totalCost;
queue.push(neibCellId, totalCost);
}
}
return null; // path is not found
}
function findWaterPath() {
const MIN_PASSABLE_TEMP = -4;
while (queue.length) {
const priority = queue.peekValue();
const next = queue.pop();
for (const neibCellId of cells.c[next]) {
if (neibCellId === exit) {
from[neibCellId] = next;
return from;
}
if (cells.h[neibCellId] >= 20) continue; // ignore land cells
if (temp[cells.g[neibCellId]] < MIN_PASSABLE_TEMP) continue; // ignore to cold cells
const distanceCost = dist2(cells.p[next], cells.p[neibCellId]);
const typeModifier = Math.abs(cells.t[neibCellId]); // 1 for coastline, 2 for deep ocean, 3 for deeper ocean
const routeModifier = cellRoutes[neibCellId] ? 1 : 2;
const connectionModifier =
connections.has(`${next}-${neibCellId}`) || connections.has(`${neibCellId}-${next}`) ? 1 : 3;
const cellsCost = distanceCost * typeModifier * routeModifier * connectionModifier;
const totalCost = priority + cellsCost;
if (from[neibCellId] || totalCost >= cost[neibCellId]) continue;
from[neibCellId] = next;
cost[neibCellId] = totalCost;
queue.push(neibCellId, totalCost);
}
}
return null; // path is not found
}
}
function restorePath(start, end, from) {
const cells = [];
let current = end;
let prev = end;
while (current !== start) {
cells.push(current);
prev = from[current];
current = prev;
}
cells.push(current);
return cells;
}
function getRouteSegments(pathCells, connections) {
const segments = [];
let segment = [];
for (let i = 0; i < pathCells.length; i++) {
const cellId = pathCells[i];
const nextCellId = pathCells[i + 1];
const isConnected = connections.has(`${cellId}-${nextCellId}`) || connections.has(`${nextCellId}-${cellId}`);
if (isConnected) {
if (segment.length) { if (segment.length) {
segment.push(current); // segment stepped into existing segment
path.push(segment); segment.push(pathCells[i]);
if (segment[0] !== end) { segments.push(segment);
cells.road[segment[0]] += score; segment = [];
cells.crossroad[segment[0]] += score;
}
if (current !== start) {
cells.road[current] += score;
cells.crossroad[current] += score;
}
} }
segment = []; continue;
prev = current;
} else {
if (prev) segment.push(prev);
prev = null;
segment.push(current);
} }
cells.road[current] += score; segment.push(pathCells[i]);
if (current === start) break;
} }
if (segment.length > 1) path.push(segment); if (segment.length > 1) segments.push(segment);
return path;
return segments;
} }
// find water paths // Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation
function findOceanPath(start, exit = null, toRoute = null) { // this gives us an aproximation of a desired road network, i.e. connections between burgs
const cells = pack.cells, // code from https://observablehq.com/@mbostock/urquhart-graph
temp = grid.cells.temp; function calculateUrquhartEdges(points) {
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); const score = (p0, p1) => dist2(points[p0], points[p1]);
const cost = [],
from = [];
queue.queue({e: start, p: 0});
while (queue.length) { const {halfedges, triangles} = Delaunator.from(points);
const next = queue.dequeue(), const n = triangles.length;
n = next.e,
p = next.p;
if (toRoute && n !== start && cells.road[n]) return [from, n, true];
for (const c of cells.c[n]) { const removed = new Uint8Array(n);
if (c === exit) { const edges = [];
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; for (let e = 0; e < n; e += 3) {
(from[c] = n), (cost[c] = totalCost); const p0 = triangles[e],
queue.queue({e: c, p: totalCost}); 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};
})(); })();

View file

@ -145,8 +145,7 @@ window.Submap = (function () {
cells.state = new Uint16Array(pn); cells.state = new Uint16Array(pn);
cells.burg = new Uint16Array(pn); cells.burg = new Uint16Array(pn);
cells.religion = new Uint16Array(pn); cells.religion = new Uint16Array(pn);
cells.road = new Uint16Array(pn); cells.route = new Uint8Array(pn);
cells.crossroad = new Uint16Array(pn);
cells.province = new Uint16Array(pn); cells.province = new Uint16Array(pn);
stage("Resampling culture, state and religion map."); stage("Resampling culture, state and religion map.");
@ -272,7 +271,7 @@ window.Submap = (function () {
BurgsAndStates.drawBurgs(); BurgsAndStates.drawBurgs();
stage("Regenerating road network."); stage("Regenerating routes network.");
Routes.regenerate(); Routes.regenerate();
drawStates(); drawStates();

View file

@ -143,7 +143,7 @@ function addBurg(point) {
const feature = cells.f[cell]; const feature = cells.f[cell];
const temple = pack.states[state].form === "Theocracy"; 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); const type = BurgsAndStates.getType(cell, false);
// generate emblem // generate emblem
@ -326,7 +326,7 @@ function createMfcgLink(burg) {
const citadel = +burg.citadel; const citadel = +burg.citadel;
const urban_castle = +(citadel && each(2)(i)); const urban_castle = +(citadel && each(2)(i));
const hub = +cells.road[cell] > 50; const hub = +cells.route[cell] === 1;
const walls = +burg.walls; const walls = +burg.walls;
const plaza = +burg.plaza; const plaza = +burg.plaza;
@ -371,7 +371,7 @@ function createVillageGeneratorLink(burg) {
else if (cells.r[cell]) tags.push("river"); else if (cells.r[cell]) tags.push("river");
else if (pop < 200 && each(4)(cell)) tags.push("pond"); 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"); if (roadsAround > 1) tags.push("highway");
else if (roadsAround === 1) tags.push("dead end"); else if (roadsAround === 1) tags.push("dead end");
else tags.push("isolated"); else tags.push("isolated");

View file

@ -281,8 +281,7 @@ function editHeightmap(options) {
const l = grid.cells.i.length; const l = grid.cells.i.length;
const biome = new Uint8Array(l); const biome = new Uint8Array(l);
const pop = new Uint16Array(l); const pop = new Uint16Array(l);
const road = new Uint16Array(l); const route = new Uint8Array(l);
const crossroad = new Uint16Array(l);
const s = new Uint16Array(l); const s = new Uint16Array(l);
const burg = new Uint16Array(l); const burg = new Uint16Array(l);
const state = new Uint16Array(l); const state = new Uint16Array(l);
@ -300,8 +299,7 @@ function editHeightmap(options) {
biome[g] = pack.cells.biome[i]; biome[g] = pack.cells.biome[i];
culture[g] = pack.cells.culture[i]; culture[g] = pack.cells.culture[i];
pop[g] = pack.cells.pop[i]; pop[g] = pack.cells.pop[i];
road[g] = pack.cells.road[i]; route[g] = pack.cells.route[i];
crossroad[g] = pack.cells.crossroad[i];
s[g] = pack.cells.s[i]; s[g] = pack.cells.s[i];
state[g] = pack.cells.state[i]; state[g] = pack.cells.state[i];
province[g] = pack.cells.province[i]; province[g] = pack.cells.province[i];
@ -353,8 +351,7 @@ function editHeightmap(options) {
// assign saved pack data from grid back to pack // assign saved pack data from grid back to pack
const n = pack.cells.i.length; const n = pack.cells.i.length;
pack.cells.pop = new Float32Array(n); pack.cells.pop = new Float32Array(n);
pack.cells.road = new Uint16Array(n); pack.cells.route = new Uint8Array(n);
pack.cells.crossroad = new Uint16Array(n);
pack.cells.s = new Uint16Array(n); pack.cells.s = new Uint16Array(n);
pack.cells.burg = new Uint16Array(n); pack.cells.burg = new Uint16Array(n);
pack.cells.state = new Uint16Array(n); pack.cells.state = new Uint16Array(n);
@ -389,8 +386,7 @@ function editHeightmap(options) {
if (!isLand) continue; if (!isLand) continue;
pack.cells.culture[i] = culture[g]; pack.cells.culture[i] = culture[g];
pack.cells.pop[i] = pop[g]; pack.cells.pop[i] = pop[g];
pack.cells.road[i] = road[g]; pack.cells.route[i] = route[g];
pack.cells.crossroad[i] = crossroad[g];
pack.cells.s[i] = s[g]; pack.cells.s[i] = s[g];
pack.cells.state[i] = state[g]; pack.cells.state[i] = state[g];
pack.cells.province[i] = province[g]; pack.cells.province[i] = province[g];

View file

@ -486,9 +486,7 @@ class RouteOpisometer extends Measurer {
const cells = pack.cells; const cells = pack.cells;
const c = findCell(mousePoint[0], mousePoint[1]); const c = findCell(mousePoint[0], mousePoint[1]);
if (!cells.road[c] && !d3.event.sourceEvent.shiftKey) { if (!cells.route[c] && !d3.event.sourceEvent.shiftKey) return;
return;
}
context.trackCell(c, rigth); context.trackCell(c, rigth);
}); });

View file

@ -129,7 +129,7 @@ function recalculatePopulation() {
if (!b.i || b.removed || b.lock) return; if (!b.i || b.removed || b.lock) return;
const i = b.cell; 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.capital) b.population = b.population * 1.3; // increase capital population
if (b.port) b.population = b.population * 1.3; // increase port 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); b.population = rn(b.population * gauss(2, 3, 0.6, 20, 3), 3);

View file

@ -185,7 +185,7 @@ function editUnits() {
const burgs = pack.burgs; const burgs = pack.burgs;
const point = d3.mouse(this); const point = d3.mouse(this);
const c = findCell(point[0], point[1]); 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 b = cells.burg[c];
const x = b ? burgs[b].x : cells.p[c][0]; const x = b ? burgs[b].x : cells.p[c][0];
const y = b ? burgs[b].y : cells.p[c][1]; const y = b ? burgs[b].y : cells.p[c][1];
@ -194,7 +194,7 @@ function editUnits() {
d3.event.on("drag", function () { d3.event.on("drag", function () {
const point = d3.mouse(this); const point = d3.mouse(this);
const c = findCell(point[0], point[1]); 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); routeOpisometer.trackCell(c, true);
} }
}); });

View file

@ -1,7 +1,9 @@
"use strict";
// FMG helper functions
// extracted d3 code to bypass version conflicts // extracted d3 code to bypass version conflicts
// https://github.com/d3/d3-array/blob/main/src/group.js // https://github.com/d3/d3-array/blob/main/src/group.js
function rollups(values, reduce, ...keys) {
export function rollups(values, reduce, ...keys) {
return nest(values, Array.from, reduce, keys); return nest(values, Array.from, reduce, keys);
} }
@ -23,3 +25,7 @@ function nest(values, map, reduce, keys) {
return map(groups); return map(groups);
})(values, 0); })(values, 0);
} }
function dist2([x1, y1], [x2, y2]) {
return (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2);
}

View file

@ -1,7 +1,7 @@
"use strict"; "use strict";
// version and caching control // 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; document.title += " v" + version;
@ -28,6 +28,7 @@ const version = "1.97.04"; // generator version, update each time
<ul> <ul>
<strong>Latest changes:</strong> <strong>Latest changes:</strong>
<li>New routes generatation algorithm</li>
<li>Preview villages map</li> <li>Preview villages map</li>
<li>Ability to render ocean heightmap</li> <li>Ability to render ocean heightmap</li>
<li>Scale bar styling features</li> <li>Scale bar styling features</li>
@ -40,9 +41,6 @@ const version = "1.97.04"; // generator version, update each time
<li>North and South Poles temperature can be set independently</li> <li>North and South Poles temperature can be set independently</li>
<li>More than 70 new heraldic charges</li> <li>More than 70 new heraldic charges</li>
<li>Multi-color heraldic charges support</li> <li>Multi-color heraldic charges support</li>
<li>New 3D scene options and improvements</li>
<li>Autosave feature (in Options)</li>
<li>Google translation support (in Options)</li>
</ul> </ul>
<p>Join our <a href="${discord}" target="_blank">Discord server</a> and <a href="${reddit}" target="_blank">Reddit community</a> to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.</p> <p>Join our <a href="${discord}" target="_blank">Discord server</a> and <a href="${reddit}" target="_blank">Reddit community</a> to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.</p>