diff --git a/src/layers/renderers/drawIce.js b/src/layers/renderers/drawIce.js index dbeb9434..c3f0b594 100644 --- a/src/layers/renderers/drawIce.js +++ b/src/layers/renderers/drawIce.js @@ -1,5 +1,6 @@ import {getGridPolygon} from "utils/graphUtils"; import {aleaPRNG} from "scripts/aleaPRNG"; +import {clipPoly} from "utils/lineUtils"; export function drawIce() { const {cells, vertices} = grid; diff --git a/src/main.js b/src/main.js index 5ade82e0..0992dc08 100644 --- a/src/main.js +++ b/src/main.js @@ -1,44 +1,16 @@ // Azgaar (azgaar.fmg@yandex.com). Minsk, 2017-2022. MIT License // https://github.com/Azgaar/Fantasy-Map-Generator -import FlatQueue from "flatqueue"; - import "./components"; -import {ERROR, INFO, TIME, WARN} from "./config/logging"; -import {UINT16_MAX} from "./constants"; import {clearLegend} from "./modules/legend"; -import {drawScaleBar, Ruler, Rulers} from "./modules/measurers"; -import {initLayers, restoreLayers, renderLayer} from "./layers"; -import {applyMapSize, applyStoredOptions, randomizeOptions} from "./modules/ui/options"; -import {applyStyleOnLoad} from "./modules/ui/stylePresets"; -import {restoreDefaultEvents} from "./scripts/events"; +import {Rulers} from "./modules/measurers"; +import {applyStoredOptions} from "./modules/ui/options"; import {addGlobalListeners} from "./scripts/listeners"; -import {locked} from "./scripts/options/lock"; -import {clearMainTip, tip} from "./scripts/tooltips"; -import {createTypedArray} from "./utils/arrayUtils"; -import {parseError} from "./utils/errorUtils"; -import {debounce} from "./utils/functionUtils"; -import { - calculateVoronoi, - findCell, - generateGrid, - getPackPolygon, - isLand, - shouldRegenerateGrid -} from "./utils/graphUtils"; -import {getAdjective} from "./utils/languageUtils"; -import {clipPoly} from "./utils/lineUtils"; -import {minmax, normalize, rn} from "./utils/numberUtils"; -import {gauss, generateSeed, P, ra, rand, rw} from "./utils/probabilityUtils"; +import {tip} from "./scripts/tooltips"; import {byId} from "./utils/shorthands"; -import {round} from "./utils/stringUtils"; -import {heightmapTemplates} from "config/heightmap-templates"; -import {aleaPRNG} from "scripts/aleaPRNG"; addGlobalListeners(); -const d3 = window.d3; - window.fmg = { modules: {} }; @@ -63,8 +35,6 @@ rulers = new Rulers(); biomesData = Biomes.getDefault(); nameBases = Names.getNameBases(); // cultures-related data -// color = d3.scaleSequential(d3.interpolateSpectral); // default color scheme - // voronoi graph extension, cannot be changed after generation graphWidth = +byId("mapWidthInput").value; graphHeight = +byId("mapHeightInput").value; @@ -74,1668 +44,8 @@ svgWidth = graphWidth; svgHeight = graphHeight; defineSvg(graphWidth, graphHeight); + scaleBar.on("mousemove", () => tip("Click to open Units Editor")).on("click", () => editUnits()); legend .on("mousemove", () => tip("Drag to change the position. Click to hide the legend")) .on("click", () => clearLegend()); - -document.on("DOMContentLoaded", async () => { - if (!location.hostname) { - const wiki = "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Run-FMG-locally"; - alertMessage.innerHTML = /* html */ `Fantasy Map Generator cannot run serverless. Follow the instructions on how you can - easily run a local web-server`; - - $("#alert").dialog({ - resizable: false, - title: "Loading error", - width: "28em", - position: {my: "center center-4em", at: "center", of: "svg"}, - buttons: { - OK: function () { - $(this).dialog("close"); - } - } - }); - } else { - hideLoading(); - await checkLoadParameters(); - } - restoreDefaultEvents(); // apply default viewbox events -}); - -function hideLoading() { - d3.select("#loading").transition().duration(3000).style("opacity", 0); - d3.select("#optionsContainer").transition().duration(2000).style("opacity", 1); - d3.select("#tooltip").transition().duration(3000).style("opacity", 1); -} - -function showLoading() { - d3.select("#loading").transition().duration(200).style("opacity", 1); - d3.select("#optionsContainer").transition().duration(100).style("opacity", 0); - d3.select("#tooltip").transition().duration(200).style("opacity", 0); -} - -// decide which map should be loaded or generated on page load -async function checkLoadParameters() { - const url = new URL(window.location.href); - const params = url.searchParams; - - // of there is a valid maplink, try to load .map file from URL - if (params.get("maplink")) { - WARN && console.warn("Load map from URL"); - const maplink = params.get("maplink"); - const pattern = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/; - const valid = pattern.test(maplink); - if (valid) { - setTimeout(() => { - loadMapFromURL(maplink, 1); - }, 1000); - return; - } else showUploadErrorMessage("Map link is not a valid URL", maplink); - } - - // if there is a seed (user of MFCG provided), generate map for it - if (params.get("seed")) { - WARN && console.warn("Generate map for seed"); - await generateMapOnLoad(); - return; - } - - // open latest map if option is active and map is stored - const loadLastMap = () => - new Promise((resolve, reject) => { - ldb.get("lastMap", blob => { - if (blob) { - WARN && console.warn("Load last saved map"); - try { - uploadMap(blob); - resolve(); - } catch (error) { - reject(error); - } - } else { - reject("No map stored"); - } - }); - }); - - if (onloadMap.value === "saved") { - try { - await loadLastMap(); - } catch (error) { - ERROR && console.error(error); - WARN && console.warn("Cannot load stored map, random map to be generated"); - await generateMapOnLoad(); - } - } else { - WARN && console.warn("Generate random map"); - await generateMapOnLoad(); - } -} - -async function generateMapOnLoad() { - await applyStyleOnLoad(); // apply previously selected default or custom style - await generate(); // generate map - focusOn(); // based on searchParams focus on point, cell or burg from MFCG - initLayers(); // apply saved layers data -} - -// focus on coordinates, cell or burg provided in searchParams -function focusOn() { - const url = new URL(window.location.href); - const params = url.searchParams; - - const fromMGCG = params.get("from") === "MFCG" && document.referrer; - if (fromMGCG) { - if (params.get("seed").length === 13) { - // show back burg from MFCG - const burgSeed = params.get("seed").slice(-4); - params.set("burg", burgSeed); - } else { - // select burg for MFCG - findBurgForMFCG(params); - return; - } - } - - const scaleParam = params.get("scale"); - const cellParam = params.get("cell"); - const burgParam = params.get("burg"); - - if (scaleParam || cellParam || burgParam) { - const scale = +scaleParam || 8; - - if (cellParam) { - const cell = +params.get("cell"); - const [x, y] = pack.cells.p[cell]; - Zoom.to(x, y, scale, 1600); - return; - } - - if (burgParam) { - const burg = isNaN(+burgParam) ? pack.burgs.find(burg => burg.name === burgParam) : pack.burgs[+burgParam]; - if (!burg) return; - - const {x, y} = burg; - Zoom.to(x, y, scale, 1600); - return; - } - - const x = +params.get("x") || graphWidth / 2; - const y = +params.get("y") || graphHeight / 2; - Zoom.to(x, y, scale, 1600); - } -} - -// find burg for MFCG and focus on it -function findBurgForMFCG(params) { - const {cells, burgs} = pack; - - if (pack.burgs.length < 2) { - ERROR && console.error("Cannot select a burg for MFCG"); - return; - } - - // used for selection - const size = +params.get("size"); - const coast = +params.get("coast"); - const port = +params.get("port"); - const river = +params.get("river"); - - let selection = defineSelection(coast, port, river); - if (!selection.length) selection = defineSelection(coast, !port, !river); - if (!selection.length) selection = defineSelection(!coast, 0, !river); - if (!selection.length) selection = [burgs[1]]; // select first if nothing is found - - function defineSelection(coast, port, river) { - if (port && river) return burgs.filter(b => b.port && cells.r[b.cell]); - if (!port && coast && river) return burgs.filter(b => !b.port && cells.t[b.cell] === 1 && cells.r[b.cell]); - if (!coast && !river) return burgs.filter(b => cells.t[b.cell] !== 1 && !cells.r[b.cell]); - if (!coast && river) return burgs.filter(b => cells.t[b.cell] !== 1 && cells.r[b.cell]); - if (coast && river) return burgs.filter(b => cells.t[b.cell] === 1 && cells.r[b.cell]); - return []; - } - - // select a burg with closest population from selection - const selected = d3.scan(selection, (a, b) => Math.abs(a.population - size) - Math.abs(b.population - size)); - const burgId = selection[selected].i; - if (!burgId) { - ERROR && console.error("Cannot select a burg for MFCG"); - return; - } - - const b = burgs[burgId]; - const referrer = new URL(document.referrer); - for (let p of referrer.searchParams) { - if (p[0] === "name") b.name = p[1]; - else if (p[0] === "size") b.population = +p[1]; - else if (p[0] === "seed") b.MFCG = +p[1]; - else if (p[0] === "shantytown") b.shanty = +p[1]; - else b[p[0]] = +p[1]; // other parameters - } - if (params.get("name") && params.get("name") != "null") b.name = params.get("name"); - - const label = burgLabels.select("[data-id='" + burgId + "']"); - if (label.size()) { - label - .text(b.name) - .classed("drag", true) - .on("mouseover", function () { - d3.select(this).classed("drag", false); - label.on("mouseover", null); - }); - } - - Zoom.to(b.x, b.y, 8, 1600); - Zoom.invoke(); - tip("Here stands the glorious city of " + b.name, true, "success", 15000); -} - -async function renderGroupCOAs(g) { - const [group, type] = - g.id === "burgEmblems" - ? [pack.burgs, "burg"] - : g.id === "provinceEmblems" - ? [pack.provinces, "province"] - : [pack.states, "state"]; - for (let use of g.children) { - const i = +use.dataset.i; - const id = type + "COA" + i; - COArenderer.trigger(id, group[i].coa); - use.setAttribute("href", "#" + id); - } -} - -// add drag to upload logic, pull request from @evyatron -void (function addDragToUpload() { - document.addEventListener("dragover", function (e) { - e.stopPropagation(); - e.preventDefault(); - byId("mapOverlay").style.display = null; - }); - - document.addEventListener("dragleave", function (e) { - byId("mapOverlay").style.display = "none"; - }); - - document.addEventListener("drop", function (e) { - e.stopPropagation(); - e.preventDefault(); - - const overlay = byId("mapOverlay"); - overlay.style.display = "none"; - if (e.dataTransfer.items == null || e.dataTransfer.items.length !== 1) return; // no files or more than one - const file = e.dataTransfer.items[0].getAsFile(); - if (file.name.indexOf(".map") == -1) { - // not a .map file - alertMessage.innerHTML = "Please upload a .map file you have previously downloaded"; - $("#alert").dialog({ - resizable: false, - title: "Invalid file format", - position: {my: "center", at: "center", of: "svg"}, - buttons: { - Close: function () { - $(this).dialog("close"); - } - } - }); - return; - } - - // all good - show uploading text and load the map - overlay.style.display = null; - overlay.innerHTML = "Uploading..."; - if (closeDialogs) closeDialogs(); - uploadMap(file, () => { - overlay.style.display = "none"; - overlay.innerHTML = "Drop a .map file to open"; - }); - }); -})(); - -async function generate(options) { - try { - const timeStart = performance.now(); - const {seed: precreatedSeed, graph: precreatedGraph} = options || {}; - - Zoom.invoke(); - setSeed(precreatedSeed); - INFO && console.group("Generated Map " + seed); - - applyMapSize(); - randomizeOptions(); - - if (shouldRegenerateGrid(grid)) grid = precreatedGraph || generateGrid(); - else delete grid.cells.h; - grid.cells.h = await HeightmapGenerator.generate(grid); - - markFeatures(); - markupGridOcean(); - addLakesInDeepDepressions(); - openNearSeaLakes(); - - OceanLayers(); - defineMapSize(); - window.mapCoordinates = calculateMapCoordinates(); - calculateTemperatures(); - generatePrecipitation(); - - reGraph(); - drawCoastline(); - - Rivers.generate(); - renderLayer("rivers"); - Lakes.defineGroup(); - defineBiomes(); - - rankCells(); - Cultures.generate(); - Cultures.expand(); - BurgsAndStates.generate(); - Religions.generate(); - BurgsAndStates.defineStateForms(); - BurgsAndStates.generateProvinces(); - BurgsAndStates.defineBurgFeatures(); - - renderLayer("states"); - renderLayer("borders"); - BurgsAndStates.drawStateLabels(); - - Rivers.specify(); - Lakes.generateName(); - - Military.generate(); - Markers.generate(); - addZones(); - - drawScaleBar(scale); - Names.getMapName(); - - WARN && console.warn(`TOTAL: ${rn((performance.now() - timeStart) / 1000, 2)}s`); - showStatistics(); - INFO && console.groupEnd("Generated Map " + seed); - } catch (error) { - ERROR && console.error(error); - const parsedError = parseError(error); - clearMainTip(); - - alertMessage.innerHTML = /* html */ `An error has occurred on map generation. Please retry.
If error is critical, clear the stored data and try again. -

${parsedError}

`; - $("#alert").dialog({ - resizable: false, - title: "Generation error", - width: "32em", - buttons: { - "Clear data": function () { - localStorage.clear(); - localStorage.setItem("version", version); - }, - Regenerate: function () { - regenerateMap("generation error"); - $(this).dialog("close"); - }, - Ignore: function () { - $(this).dialog("close"); - } - }, - position: {my: "center", at: "center", of: "svg"} - }); - } -} - -// set map seed (string!) -function setSeed(precreatedSeed) { - if (!precreatedSeed) { - const first = !mapHistory[0]; - const url = new URL(window.location.href); - const params = url.searchParams; - const urlSeed = url.searchParams.get("seed"); - if (first && params.get("from") === "MFCG" && urlSeed.length === 13) seed = urlSeed.slice(0, -4); - else if (first && urlSeed) seed = urlSeed; - else if (optionsSeed.value && optionsSeed.value != seed) seed = optionsSeed.value; - else seed = generateSeed(); - } else { - seed = precreatedSeed; - } - - byId("optionsSeed").value = seed; - Math.random = aleaPRNG(seed); -} - -// Mark features (ocean, lakes, islands) and calculate distance field -function markFeatures() { - TIME && console.time("markFeatures"); - Math.random = aleaPRNG(seed); // get the same result on heightmap edit in Erase mode - - const cells = grid.cells; - const heights = grid.cells.h; - cells.f = new Uint16Array(cells.i.length); // cell feature number - cells.t = new Int8Array(cells.i.length); // cell type: 1 = land coast; -1 = water near coast - grid.features = [0]; - - for (let i = 1, queue = [0]; queue[0] !== -1; i++) { - cells.f[queue[0]] = i; // feature number - const land = heights[queue[0]] >= 20; - let border = false; // true if feature touches map border - - while (queue.length) { - const q = queue.pop(); - if (cells.b[q]) border = true; - - cells.c[q].forEach(c => { - const cLand = heights[c] >= 20; - if (land === cLand && !cells.f[c]) { - cells.f[c] = i; - queue.push(c); - } else if (land && !cLand) { - cells.t[q] = 1; - cells.t[c] = -1; - } - }); - } - const type = land ? "island" : border ? "ocean" : "lake"; - grid.features.push({i, land, border, type}); - - queue[0] = cells.f.findIndex(f => !f); // find unmarked cell - } - - TIME && console.timeEnd("markFeatures"); -} - -function markupGridOcean() { - TIME && console.time("markupGridOcean"); - markup(grid.cells, -2, -1, -10); - TIME && console.timeEnd("markupGridOcean"); -} - -// Calculate cell-distance to coast for every cell -function markup(cells, start, increment, limit) { - for (let t = start, count = Infinity; count > 0 && t > limit; t += increment) { - count = 0; - const prevT = t - increment; - for (let i = 0; i < cells.i.length; i++) { - if (cells.t[i] !== prevT) continue; - - for (const c of cells.c[i]) { - if (cells.t[c]) continue; - cells.t[c] = t; - count++; - } - } - } -} - -function addLakesInDeepDepressions() { - TIME && console.time("addLakesInDeepDepressions"); - const {cells, features} = grid; - const {c, h, b} = cells; - const ELEVATION_LIMIT = +byId("lakeElevationLimitOutput").value; - if (ELEVATION_LIMIT === 80) return; - - for (const i of cells.i) { - if (b[i] || h[i] < 20) continue; - - const minHeight = d3.min(c[i].map(c => h[c])); - if (h[i] > minHeight) continue; - - let deep = true; - const threshold = h[i] + ELEVATION_LIMIT; - const queue = [i]; - const checked = []; - checked[i] = true; - - // check if elevated cell can potentially pour to water - while (deep && queue.length) { - const q = queue.pop(); - - for (const n of c[q]) { - if (checked[n]) continue; - if (h[n] >= threshold) continue; - if (h[n] < 20) { - deep = false; - break; - } - - checked[n] = true; - queue.push(n); - } - } - - // if not, add a lake - if (deep) { - const lakeCells = [i].concat(c[i].filter(n => h[n] === h[i])); - addLake(lakeCells); - } - } - - function addLake(lakeCells) { - const f = features.length; - - lakeCells.forEach(i => { - cells.h[i] = 19; - cells.t[i] = -1; - cells.f[i] = f; - c[i].forEach(n => !lakeCells.includes(n) && (cells.t[c] = 1)); - }); - - features.push({i: f, land: false, border: false, type: "lake"}); - } - - TIME && console.timeEnd("addLakesInDeepDepressions"); -} - -// near sea lakes usually get a lot of water inflow, most of them should brake threshold and flow out to sea (see Ancylus Lake) -function openNearSeaLakes() { - if (byId("templateInput").value === "Atoll") return; // no need for Atolls - - const cells = grid.cells; - const features = grid.features; - if (!features.find(f => f.type === "lake")) return; // no lakes - TIME && console.time("openLakes"); - const LIMIT = 22; // max height that can be breached by water - - for (const i of cells.i) { - const lake = cells.f[i]; - if (features[lake].type !== "lake") continue; // not a lake cell - - check_neighbours: for (const c of cells.c[i]) { - if (cells.t[c] !== 1 || cells.h[c] > LIMIT) continue; // water cannot brake this - - for (const n of cells.c[c]) { - const ocean = cells.f[n]; - if (features[ocean].type !== "ocean") continue; // not an ocean - removeLake(c, lake, ocean); - break check_neighbours; - } - } - } - - function removeLake(threshold, lake, ocean) { - cells.h[threshold] = 19; - cells.t[threshold] = -1; - cells.f[threshold] = ocean; - cells.c[threshold].forEach(function (c) { - if (cells.h[c] >= 20) cells.t[c] = 1; // mark as coastline - }); - features[lake].type = "ocean"; // mark former lake as ocean - } - - TIME && console.timeEnd("openLakes"); -} - -// define map size and position based on template and random factor -function defineMapSize() { - const [size, latitude] = getSizeAndLatitude(); - const randomize = new URL(window.location.href).searchParams.get("options") === "default"; // ignore stored options - if (randomize || !locked("mapSize")) mapSizeOutput.value = mapSizeInput.value = rn(size); - if (randomize || !locked("latitude")) latitudeOutput.value = latitudeInput.value = rn(latitude); - - function getSizeAndLatitude() { - const template = byId("templateInput").value; // heightmap template - - if (template === "africa-centric") return [45, 53]; - if (template === "arabia") return [20, 35]; - if (template === "atlantics") return [42, 23]; - if (template === "britain") return [7, 20]; - if (template === "caribbean") return [15, 40]; - if (template === "east-asia") return [11, 28]; - if (template === "eurasia") return [38, 19]; - if (template === "europe") return [20, 16]; - if (template === "europe-accented") return [14, 22]; - if (template === "europe-and-central-asia") return [25, 10]; - if (template === "europe-central") return [11, 22]; - if (template === "europe-north") return [7, 18]; - if (template === "greenland") return [22, 7]; - if (template === "hellenica") return [8, 27]; - if (template === "iceland") return [2, 15]; - if (template === "indian-ocean") return [45, 55]; - if (template === "mediterranean-sea") return [10, 29]; - if (template === "middle-east") return [8, 31]; - if (template === "north-america") return [37, 17]; - if (template === "us-centric") return [66, 27]; - if (template === "us-mainland") return [16, 30]; - if (template === "world") return [78, 27]; - if (template === "world-from-pacific") return [75, 32]; - - const part = grid.features.some(f => f.land && f.border); // if land goes over map borders - const max = part ? 80 : 100; // max size - const lat = () => gauss(P(0.5) ? 40 : 60, 15, 25, 75); // latitude shift - - if (!part) { - if (template === "Pangea") return [100, 50]; - if (template === "Shattered" && P(0.7)) return [100, 50]; - if (template === "Continents" && P(0.5)) return [100, 50]; - if (template === "Archipelago" && P(0.35)) return [100, 50]; - if (template === "High Island" && P(0.25)) return [100, 50]; - if (template === "Low Island" && P(0.1)) return [100, 50]; - } - - if (template === "Pangea") return [gauss(70, 20, 30, max), lat()]; - if (template === "Volcano") return [gauss(20, 20, 10, max), lat()]; - if (template === "Mediterranean") return [gauss(25, 30, 15, 80), lat()]; - if (template === "Peninsula") return [gauss(15, 15, 5, 80), lat()]; - if (template === "Isthmus") return [gauss(15, 20, 3, 80), lat()]; - if (template === "Atoll") return [gauss(5, 10, 2, max), lat()]; - - return [gauss(30, 20, 15, max), lat()]; // Continents, Archipelago, High Island, Low Island - } -} - -// calculate map position on globe -function calculateMapCoordinates() { - const size = +byId("mapSizeOutput").value; - const latShift = +byId("latitudeOutput").value; - - const latT = rn((size / 100) * 180, 1); - const latN = rn(90 - ((180 - latT) * latShift) / 100, 1); - const latS = rn(latN - latT, 1); - - const lon = rn(Math.min(((graphWidth / graphHeight) * latT) / 2, 180)); - return {latT, latN, latS, lonT: lon * 2, lonW: -lon, lonE: lon}; -} - -// temperature model -function calculateTemperatures() { - TIME && console.time("calculateTemperatures"); - const cells = grid.cells; - cells.temp = new Int8Array(cells.i.length); // temperature array - - const tEq = +temperatureEquatorInput.value; - const tPole = +temperaturePoleInput.value; - const tDelta = tEq - tPole; - const int = d3.easePolyInOut.exponent(0.5); // interpolation function - - d3.range(0, cells.i.length, grid.cellsX).forEach(function (r) { - const y = grid.points[r][1]; - const lat = Math.abs(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT); // [0; 90] - const initTemp = tEq - int(lat / 90) * tDelta; - for (let i = r; i < r + grid.cellsX; i++) { - cells.temp[i] = minmax(initTemp - convertToFriendly(cells.h[i]), -128, 127); - } - }); - - // temperature decreases by 6.5 degree C per 1km - function convertToFriendly(h) { - if (h < 20) return 0; - const exponent = +heightExponentInput.value; - const height = Math.pow(h - 18, exponent); - return rn((height / 1000) * 6.5); - } - - TIME && console.timeEnd("calculateTemperatures"); -} - -// simplest precipitation model -function generatePrecipitation() { - TIME && console.time("generatePrecipitation"); - prec.selectAll("*").remove(); - const {cells, cellsX, cellsY} = grid; - cells.prec = new Uint8Array(cells.i.length); // precipitation array - - const cellsNumberModifier = (pointsInput.dataset.cells / 10000) ** 0.25; - const precInputModifier = precInput.value / 100; - const modifier = cellsNumberModifier * precInputModifier; - - const westerly = []; - const easterly = []; - let southerly = 0; - let northerly = 0; - - // precipitation modifier per latitude band - // x4 = 0-5 latitude: wet through the year (rising zone) - // x2 = 5-20 latitude: wet summer (rising zone), dry winter (sinking zone) - // x1 = 20-30 latitude: dry all year (sinking zone) - // x2 = 30-50 latitude: wet winter (rising zone), dry summer (sinking zone) - // x3 = 50-60 latitude: wet all year (rising zone) - // x2 = 60-70 latitude: wet summer (rising zone), dry winter (sinking zone) - // x1 = 70-85 latitude: dry all year (sinking zone) - // x0.5 = 85-90 latitude: dry all year (sinking zone) - const latitudeModifier = [4, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 3, 2, 2, 1, 1, 1, 0.5]; - const MAX_PASSABLE_ELEVATION = 85; - - // define wind directions based on cells latitude and prevailing winds there - d3.range(0, cells.i.length, cellsX).forEach(function (c, i) { - const lat = mapCoordinates.latN - (i / cellsY) * mapCoordinates.latT; - const latBand = ((Math.abs(lat) - 1) / 5) | 0; - const latMod = latitudeModifier[latBand]; - const windTier = (Math.abs(lat - 89) / 30) | 0; // 30d tiers from 0 to 5 from N to S - const {isWest, isEast, isNorth, isSouth} = getWindDirections(windTier); - - if (isWest) westerly.push([c, latMod, windTier]); - if (isEast) easterly.push([c + cellsX - 1, latMod, windTier]); - if (isNorth) northerly++; - if (isSouth) southerly++; - }); - - // distribute winds by direction - if (westerly.length) passWind(westerly, 120 * modifier, 1, cellsX); - if (easterly.length) passWind(easterly, 120 * modifier, -1, cellsX); - - const vertT = southerly + northerly; - if (northerly) { - const bandN = ((Math.abs(mapCoordinates.latN) - 1) / 5) | 0; - const latModN = mapCoordinates.latT > 60 ? d3.mean(latitudeModifier) : latitudeModifier[bandN]; - const maxPrecN = (northerly / vertT) * 60 * modifier * latModN; - passWind(d3.range(0, cellsX, 1), maxPrecN, cellsX, cellsY); - } - - if (southerly) { - const bandS = ((Math.abs(mapCoordinates.latS) - 1) / 5) | 0; - const latModS = mapCoordinates.latT > 60 ? d3.mean(latitudeModifier) : latitudeModifier[bandS]; - const maxPrecS = (southerly / vertT) * 60 * modifier * latModS; - passWind(d3.range(cells.i.length - cellsX, cells.i.length, 1), maxPrecS, -cellsX, cellsY); - } - - function getWindDirections(tier) { - const angle = options.winds[tier]; - - const isWest = angle > 40 && angle < 140; - const isEast = angle > 220 && angle < 320; - const isNorth = angle > 100 && angle < 260; - const isSouth = angle > 280 || angle < 80; - - return {isWest, isEast, isNorth, isSouth}; - } - - function passWind(source, maxPrec, next, steps) { - const maxPrecInit = maxPrec; - - for (let first of source) { - if (first[0]) { - maxPrec = Math.min(maxPrecInit * first[1], 255); - first = first[0]; - } - - let humidity = maxPrec - cells.h[first]; // initial water amount - if (humidity <= 0) continue; // if first cell in row is too elevated consider wind dry - - for (let s = 0, current = first; s < steps; s++, current += next) { - if (cells.temp[current] < -5) continue; // no flux in permafrost - - if (cells.h[current] < 20) { - // water cell - if (cells.h[current + next] >= 20) { - cells.prec[current + next] += Math.max(humidity / rand(10, 20), 1); // coastal precipitation - } else { - humidity = Math.min(humidity + 5 * modifier, maxPrec); // wind gets more humidity passing water cell - cells.prec[current] += 5 * modifier; // water cells precipitation (need to correctly pour water through lakes) - } - continue; - } - - // land cell - const isPassable = cells.h[current + next] <= MAX_PASSABLE_ELEVATION; - const precipitation = isPassable ? getPrecipitation(humidity, current, next) : humidity; - cells.prec[current] += precipitation; - const evaporation = precipitation > 1.5 ? 1 : 0; // some humidity evaporates back to the atmosphere - humidity = isPassable ? minmax(humidity - precipitation + evaporation, 0, maxPrec) : 0; - } - } - } - - function getPrecipitation(humidity, i, n) { - const normalLoss = Math.max(humidity / (10 * modifier), 1); // precipitation in normal conditions - const diff = Math.max(cells.h[i + n] - cells.h[i], 0); // difference in height - const mod = (cells.h[i + n] / 70) ** 2; // 50 stands for hills, 70 for mountains - return minmax(normalLoss + diff * mod, 1, humidity); - } - - void (function drawWindDirection() { - const wind = prec.append("g").attr("id", "wind"); - - d3.range(0, 6).forEach(function (t) { - if (westerly.length > 1) { - const west = westerly.filter(w => w[2] === t); - if (west && west.length > 3) { - const from = west[0][0], - to = west[west.length - 1][0]; - const y = (grid.points[from][1] + grid.points[to][1]) / 2; - wind.append("text").attr("x", 20).attr("y", y).text("\u21C9"); - } - } - if (easterly.length > 1) { - const east = easterly.filter(w => w[2] === t); - if (east && east.length > 3) { - const from = east[0][0], - to = east[east.length - 1][0]; - const y = (grid.points[from][1] + grid.points[to][1]) / 2; - wind - .append("text") - .attr("x", graphWidth - 52) - .attr("y", y) - .text("\u21C7"); - } - } - }); - - if (northerly) - wind - .append("text") - .attr("x", graphWidth / 2) - .attr("y", 42) - .text("\u21CA"); - if (southerly) - wind - .append("text") - .attr("x", graphWidth / 2) - .attr("y", graphHeight - 20) - .text("\u21C8"); - })(); - - TIME && console.timeEnd("generatePrecipitation"); -} - -// recalculate Voronoi Graph to pack cells -function reGraph() { - TIME && console.time("reGraph"); - const {cells: gridCells, points, features} = grid; - const newCells = {p: [], g: [], h: []}; // store new data - const spacing2 = grid.spacing ** 2; - - 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]; - - 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 - gridCells.c[i].forEach(function (e) { - if (i > e) return; - if (gridCells.t[e] === type) { - const dist2 = (y - points[e][1]) ** 2 + (x - points[e][0]) ** 2; - if (dist2 < spacing2) return; // too close to each other - const x1 = rn((x + points[e][0]) / 2, 1); - const y1 = rn((y + points[e][1]) / 2, 1); - addNewPoint(i, x1, y1, height); - } - }); - } - } - - function addNewPoint(i, x, y, height) { - newCells.p.push([x, y]); - newCells.g.push(i); - newCells.h.push(height); - } - - function getCellArea(i) { - const area = Math.abs(d3.polygonArea(getPackPolygon(i))); - return Math.min(area, UINT16_MAX); - } - - const {cells: packCells, vertices} = calculateVoronoi(newCells.p, grid.boundary); - pack.vertices = vertices; - pack.cells = packCells; - pack.cells.p = newCells.p; - pack.cells.g = createTypedArray({maxValue: grid.points.length, from: newCells.g}); - pack.cells.q = d3.quadtree(newCells.p.map(([x, y], i) => [x, y, i])); - pack.cells.h = createTypedArray({maxValue: 100, from: newCells.h}); - pack.cells.area = createTypedArray({maxValue: UINT16_MAX, from: pack.cells.i}).map(getCellArea); - - TIME && console.timeEnd("reGraph"); -} - -// Detect and draw the coastline -function drawCoastline() { - TIME && console.time("drawCoastline"); - reMarkFeatures(); - - const {cells, vertices, features} = pack; - const n = cells.i.length; - - const used = new Uint8Array(features.length); // store connected features - const largestLand = d3.scan( - features.map(f => (f.land ? f.cells : 0)), - (a, b) => b - a - ); - const landMask = defs.select("#land"); - const waterMask = defs.select("#water"); - const lineGen = d3.line().curve(d3.curveBasisClosed); - - for (const i of cells.i) { - const startFromEdge = !i && cells.h[i] >= 20; - if (!startFromEdge && cells.t[i] !== -1 && cells.t[i] !== 1) continue; // non-edge cell - const f = cells.f[i]; - if (used[f]) continue; // already connected - if (features[f].type === "ocean") continue; // ocean cell - - const type = features[f].type === "lake" ? 1 : -1; // type value to search for - const start = findStart(i, type); - if (start === -1) continue; // cannot start here - let vchain = connectVertices(start, type); - if (features[f].type === "lake") relax(vchain, 1.2); - used[f] = 1; - let points = clipPoly( - vchain.map(v => vertices.p[v]), - 1 - ); - const area = d3.polygonArea(points); // area with lakes/islands - if (area > 0 && features[f].type === "lake") { - points = points.reverse(); - vchain = vchain.reverse(); - } - - features[f].area = Math.abs(area); - features[f].vertices = vchain; - - const path = round(lineGen(points)); - if (features[f].type === "lake") { - landMask - .append("path") - .attr("d", path) - .attr("fill", "black") - .attr("id", "land_" + f); - // waterMask.append("path").attr("d", path).attr("fill", "white").attr("id", "water_"+id); // uncomment to show over lakes - lakes - .select("#freshwater") - .append("path") - .attr("d", path) - .attr("id", "lake_" + f) - .attr("data-f", f); // draw the lake - } else { - landMask - .append("path") - .attr("d", path) - .attr("fill", "white") - .attr("id", "land_" + f); - waterMask - .append("path") - .attr("d", path) - .attr("fill", "black") - .attr("id", "water_" + f); - const g = features[f].group === "lake_island" ? "lake_island" : "sea_island"; - coastline - .select("#" + g) - .append("path") - .attr("d", path) - .attr("id", "island_" + f) - .attr("data-f", f); // draw the coastline - } - - // draw ruler to cover the biggest land piece - if (f === largestLand) { - const from = points[d3.scan(points, (a, b) => a[0] - b[0])]; - const to = points[d3.scan(points, (a, b) => b[0] - a[0])]; - rulers.create(Ruler, [from, to]); - } - } - - // find cell vertex to start path detection - function findStart(i, t) { - if (t === -1 && cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= n)); // map border cell - const filtered = cells.c[i].filter(c => cells.t[c] === t); - const index = cells.c[i].indexOf(d3.min(filtered)); - return index === -1 ? index : cells.v[i][index]; - } - - // connect vertices to chain - function connectVertices(start, t) { - const chain = []; // vertices chain to form a path - for (let i = 0, current = start; i === 0 || (current !== start && i < 50000); i++) { - const prev = chain[chain.length - 1]; // previous vertex in chain - chain.push(current); // add current vertex to sequence - const c = vertices.c[current]; // cells adjacent to vertex - const v = vertices.v[current]; // neighboring vertices - const c0 = c[0] >= n || cells.t[c[0]] === t; - const c1 = c[1] >= n || cells.t[c[1]] === t; - const c2 = c[2] >= n || cells.t[c[2]] === t; - if (v[0] !== prev && c0 !== c1) current = v[0]; - else if (v[1] !== prev && c1 !== c2) current = v[1]; - else if (v[2] !== prev && c0 !== c2) current = v[2]; - if (current === chain[chain.length - 1]) { - ERROR && console.error("Next vertex is not found"); - break; - } - } - return chain; - } - - // move vertices that are too close to already added ones - function relax(vchain, r) { - const p = vertices.p, - tree = d3.quadtree(); - - for (let i = 0; i < vchain.length; i++) { - const v = vchain[i]; - let [x, y] = [p[v][0], p[v][1]]; - if (i && vchain[i + 1] && tree.find(x, y, r) !== undefined) { - const v1 = vchain[i - 1], - v2 = vchain[i + 1]; - const [x1, y1] = [p[v1][0], p[v1][1]]; - const [x2, y2] = [p[v2][0], p[v2][1]]; - [x, y] = [(x1 + x2) / 2, (y1 + y2) / 2]; - p[v] = [x, y]; - } - tree.add([x, y]); - } - } - - TIME && console.timeEnd("drawCoastline"); -} - -// Re-mark features (ocean, lakes, islands) -function reMarkFeatures() { - TIME && console.time("reMarkFeatures"); - const cells = pack.cells, - features = (pack.features = [0]); - cells.f = new Uint16Array(cells.i.length); // cell feature number - cells.t = new Int8Array(cells.i.length); // cell type: 1 = land along coast; -1 = water along coast; - cells.haven = cells.i.length < 65535 ? new Uint16Array(cells.i.length) : new Uint32Array(cells.i.length); // cell haven (opposite water cell); - cells.harbor = new Uint8Array(cells.i.length); // cell harbor (number of adjacent water cells); - - const defineHaven = i => { - const water = cells.c[i].filter(c => cells.h[c] < 20); - const dist2 = water.map(c => (cells.p[i][0] - cells.p[c][0]) ** 2 + (cells.p[i][1] - cells.p[c][1]) ** 2); - const closest = water[dist2.indexOf(Math.min.apply(Math, dist2))]; - - cells.haven[i] = closest; - cells.harbor[i] = water.length; - }; - - if (!cells.i.length) return; // no cells -> there is nothing to do - for (let i = 1, queue = [0]; queue[0] !== -1; i++) { - const start = queue[0]; // first cell - cells.f[start] = i; // assign feature number - const land = cells.h[start] >= 20; - let border = false; // true if feature touches map border - let cellNumber = 1; // to count cells number in a feature - - while (queue.length) { - const q = queue.pop(); - if (cells.b[q]) border = true; - cells.c[q].forEach(function (e) { - const eLand = cells.h[e] >= 20; - if (land && !eLand) { - cells.t[q] = 1; - cells.t[e] = -1; - if (!cells.haven[q]) defineHaven(q); - } else if (land && eLand) { - if (!cells.t[e] && cells.t[q] === 1) cells.t[e] = 2; - else if (!cells.t[q] && cells.t[e] === 1) cells.t[q] = 2; - } - if (!cells.f[e] && land === eLand) { - queue.push(e); - cells.f[e] = i; - cellNumber++; - } - }); - } - - const type = land ? "island" : border ? "ocean" : "lake"; - let group; - if (type === "ocean") group = defineOceanGroup(cellNumber); - else if (type === "island") group = defineIslandGroup(start, cellNumber); - features.push({i, land, border, type, cells: cellNumber, firstCell: start, group}); - queue[0] = cells.f.findIndex(f => !f); // find unmarked cell - } - - // markupPackLand - markup(pack.cells, 3, 1, 0); - - function defineOceanGroup(number) { - if (number > grid.cells.i.length / 25) return "ocean"; - if (number > grid.cells.i.length / 100) return "sea"; - return "gulf"; - } - - function defineIslandGroup(cell, number) { - if (cell && features[cells.f[cell - 1]].type === "lake") return "lake_island"; - if (number > grid.cells.i.length / 10) return "continent"; - if (number > grid.cells.i.length / 1000) return "island"; - return "isle"; - } - - TIME && console.timeEnd("reMarkFeatures"); -} - -function isWetLand(moisture, temperature, height) { - if (moisture > 40 && temperature > -2 && height < 25) return true; //near coast - if (moisture > 24 && temperature > -2 && height > 24 && height < 60) return true; //off coast - return false; -} - -// assign biome id for each cell -function defineBiomes() { - TIME && console.time("defineBiomes"); - const {cells} = pack; - const {temp, prec} = grid.cells; - cells.biome = new Uint8Array(cells.i.length); // biomes array - - for (const i of cells.i) { - const temperature = temp[cells.g[i]]; - const height = cells.h[i]; - const moisture = height < 20 ? 0 : calculateMoisture(i); - cells.biome[i] = getBiomeId(moisture, temperature, height); - } - - function calculateMoisture(i) { - let moist = prec[cells.g[i]]; - if (cells.r[i]) moist += Math.max(cells.fl[i] / 20, 2); - - const n = cells.c[i] - .filter(isLand) - .map(c => prec[cells.g[c]]) - .concat([moist]); - return rn(4 + d3.mean(n)); - } - - TIME && console.timeEnd("defineBiomes"); -} - -// assign biome id to a cell -function getBiomeId(moisture, temperature, height) { - if (height < 20) return 0; // marine biome: all water cells - if (temperature < -5) return 11; // permafrost biome - if (isWetLand(moisture, temperature, height)) return 12; // wetland biome - - const moistureBand = Math.min((moisture / 5) | 0, 4); // [0-4] - const temperatureBand = Math.min(Math.max(20 - temperature, 0), 25); // [0-25] - return biomesData.biomesMartix[moistureBand][temperatureBand]; -} - -// assess cells suitability to calculate population and rand cells for culture center and burgs placement -function rankCells() { - TIME && console.time("rankCells"); - const {cells, features} = pack; - cells.s = new Int16Array(cells.i.length); // cell suitability array - cells.pop = new Float32Array(cells.i.length); // cell population array - - const flMean = d3.median(cells.fl.filter(f => f)) || 0, - flMax = d3.max(cells.fl) + d3.max(cells.conf); // to normalize flux - const areaMean = d3.mean(cells.area); // to adjust population by cell area - - for (const i of cells.i) { - if (cells.h[i] < 20) continue; // no population in water - let s = +biomesData.habitability[cells.biome[i]]; // base suitability derived from biome habitability - if (!s) continue; // uninhabitable biomes has 0 suitability - if (flMean) s += normalize(cells.fl[i] + cells.conf[i], flMean, flMax) * 250; // big rivers and confluences are valued - s -= (cells.h[i] - 50) / 5; // low elevation is valued, high is not; - - if (cells.t[i] === 1) { - if (cells.r[i]) s += 15; // estuary is valued - const feature = features[cells.f[cells.haven[i]]]; - if (feature.type === "lake") { - if (feature.group === "freshwater") s += 30; - else if (feature.group == "salt") s += 10; - else if (feature.group == "frozen") s += 1; - else if (feature.group == "dry") s -= 5; - else if (feature.group == "sinkhole") s -= 5; - else if (feature.group == "lava") s -= 30; - } else { - s += 5; // ocean coast is valued - if (cells.harbor[i] === 1) s += 20; // safe sea harbor is valued - } - } - - cells.s[i] = s / 5; // general population rate - // cell rural population is suitability adjusted by cell area - cells.pop[i] = cells.s[i] > 0 ? (cells.s[i] * cells.area[i]) / areaMean : 0; - } - - TIME && console.timeEnd("rankCells"); -} - -// generate zones -function addZones(number = 1) { - TIME && console.time("addZones"); - const {cells, states, burgs} = pack; - const used = new Uint8Array(cells.i.length); // to store used cells - const zonesData = []; - - for (let i = 0; i < rn(Math.random() * 1.8 * number); i++) addInvasion(); // invasion of enemy lands - for (let i = 0; i < rn(Math.random() * 1.6 * number); i++) addRebels(); // rebels along a state border - for (let i = 0; i < rn(Math.random() * 1.6 * number); i++) addProselytism(); // proselitism of organized religion - for (let i = 0; i < rn(Math.random() * 1.6 * number); i++) addCrusade(); // crusade on heresy lands - for (let i = 0; i < rn(Math.random() * 1.8 * number); i++) addDisease(); // disease starting in a random city - for (let i = 0; i < rn(Math.random() * 1.4 * number); i++) addDisaster(); // disaster starting in a random city - for (let i = 0; i < rn(Math.random() * 1.4 * number); i++) addEruption(); // volcanic eruption aroung volcano - for (let i = 0; i < rn(Math.random() * 1.0 * number); i++) addAvalanche(); // avalanche impacting highland road - for (let i = 0; i < rn(Math.random() * 1.4 * number); i++) addFault(); // fault line in elevated areas - for (let i = 0; i < rn(Math.random() * 1.4 * number); i++) addFlood(); // flood on river banks - for (let i = 0; i < rn(Math.random() * 1.2 * number); i++) addTsunami(); // tsunami starting near coast - - drawZones(); - - function addInvasion() { - const atWar = states.filter(s => s.diplomacy && s.diplomacy.some(d => d === "Enemy")); - if (!atWar.length) return; - - const invader = ra(atWar); - const target = invader.diplomacy.findIndex(d => d === "Enemy"); - - const cell = ra( - cells.i.filter(i => cells.state[i] === target && cells.c[i].some(c => cells.state[c] === invader.i)) - ); - if (!cell) return; - - const cellsArray = [], - queue = [cell], - power = rand(5, 30); - - while (queue.length) { - const q = P(0.4) ? queue.shift() : queue.pop(); - cellsArray.push(q); - if (cellsArray.length > power) break; - - cells.c[q].forEach(e => { - if (used[e]) return; - if (cells.state[e] !== target) return; - used[e] = 1; - queue.push(e); - }); - } - - const invasion = rw({ - Invasion: 4, - Occupation: 3, - Raid: 2, - Conquest: 2, - Subjugation: 1, - Foray: 1, - Skirmishes: 1, - Incursion: 2, - Pillaging: 1, - Intervention: 1 - }); - const name = getAdjective(invader.name) + " " + invasion; - zonesData.push({name, type: "Invasion", cells: cellsArray, fill: "url(#hatch1)"}); - } - - function addRebels() { - const state = ra(states.filter(s => s.i && !s.removed && s.neighbors.some(n => n))); - if (!state) return; - - const neib = ra(state.neighbors.filter(n => n && !states[n].removed)); - if (!neib) return; - const cell = cells.i.find( - i => cells.state[i] === state.i && !state.removed && cells.c[i].some(c => cells.state[c] === neib) - ); - const cellsArray = []; - const queue = []; - if (cell) queue.push(cell); - - const power = rand(10, 30); - - while (queue.length) { - const q = queue.shift(); - cellsArray.push(q); - if (cellsArray.length > power) break; - - cells.c[q].forEach(e => { - if (used[e]) return; - if (cells.state[e] !== state.i) return; - used[e] = 1; - if (e % 4 !== 0 && !cells.c[e].some(c => cells.state[c] === neib)) return; - queue.push(e); - }); - } - - const rebels = rw({ - Rebels: 5, - Insurgents: 2, - Mutineers: 1, - Rioters: 1, - Separatists: 1, - Secessionists: 1, - Insurrection: 2, - Rebellion: 1, - Conspiracy: 2 - }); - const name = getAdjective(states[neib].name) + " " + rebels; - zonesData.push({name, type: "Rebels", cells: cellsArray, fill: "url(#hatch3)"}); - } - - function addProselytism() { - const organized = ra(pack.religions.filter(r => r.type === "Organized")); - if (!organized) return; - - const cell = ra( - cells.i.filter( - i => - cells.religion[i] && - cells.religion[i] !== organized.i && - cells.c[i].some(c => cells.religion[c] === organized.i) - ) - ); - if (!cell) return; - const target = cells.religion[cell]; - const cellsArray = [], - queue = [cell], - power = rand(10, 30); - - while (queue.length) { - const q = queue.shift(); - cellsArray.push(q); - if (cellsArray.length > power) break; - - cells.c[q].forEach(e => { - if (used[e]) return; - if (cells.religion[e] !== target) return; - if (cells.h[e] < 20) return; - used[e] = 1; - //if (e%2 !== 0 && !cells.c[e].some(c => cells.state[c] === neib)) return; - queue.push(e); - }); - } - - const name = getAdjective(organized.name.split(" ")[0]) + " Proselytism"; - zonesData.push({name, type: "Proselytism", cells: cellsArray, fill: "url(#hatch6)"}); - } - - function addCrusade() { - const heresy = ra(pack.religions.filter(r => r.type === "Heresy")); - if (!heresy) return; - - const cellsArray = cells.i.filter(i => !used[i] && cells.religion[i] === heresy.i); - if (!cellsArray.length) return; - cellsArray.forEach(i => (used[i] = 1)); - - const name = getAdjective(heresy.name.split(" ")[0]) + " Crusade"; - zonesData.push({name, type: "Crusade", cells: cellsArray, fill: "url(#hatch6)"}); - } - - function addDisease() { - const burg = ra(burgs.filter(b => !used[b.cell] && b.i && !b.removed)); // random burg - if (!burg) return; - - const cellsArray = []; - const costs = []; - const power = rand(20, 37); - - const queue = new FlatQueue(); - queue.push(burg.cell, 0); - - while (queue.length) { - const priority = queue.peekValue(); - const next = queue.pop(); - - if (cells.burg[next] || cells.pop[next]) cellsArray.push(next); - used[next] = 1; - - cells.c[next].forEach(neibCellId => { - const roadValue = cells.road[next]; - const cost = roadValue ? Math.max(10 - roadValue, 1) : 100; - const totalPriority = priority + cost; - if (totalPriority > power) return; - - if (!costs[neibCellId] || totalPriority < costs[neibCellId]) { - costs[neibCellId] = totalPriority; - queue.push(neibCellId, totalPriority); - } - }); - } - - const adjective = () => - ra(["Great", "Silent", "Severe", "Blind", "Unknown", "Loud", "Deadly", "Burning", "Bloody", "Brutal", "Fatal"]); - const animal = () => - ra([ - "Ape", - "Bear", - "Boar", - "Cat", - "Cow", - "Dog", - "Pig", - "Fox", - "Bird", - "Horse", - "Rat", - "Raven", - "Sheep", - "Spider", - "Wolf" - ]); - const color = () => - ra([ - "Golden", - "White", - "Black", - "Red", - "Pink", - "Purple", - "Blue", - "Green", - "Yellow", - "Amber", - "Orange", - "Brown", - "Grey" - ]); - - const type = rw({ - Fever: 5, - Pestilence: 2, - Flu: 2, - Pox: 2, - Smallpox: 2, - Plague: 4, - Cholera: 2, - Dropsy: 1, - Leprosy: 2 - }); - const name = rw({[color()]: 4, [animal()]: 2, [adjective()]: 1}) + " " + type; - zonesData.push({name, type: "Disease", cells: cellsArray, fill: "url(#hatch12)"}); - } - - function addDisaster() { - const burg = ra(burgs.filter(b => !used[b.cell] && b.i && !b.removed)); // random burg - if (!burg) return; - - const cellsArray = []; - const costs = []; - const power = rand(5, 25); - - const queue = new FlatQueue(); - queue.push(burg.cell, 0); - - while (queue.length) { - const priority = queue.peekValue(); - const next = queue.pop(); - - if (cells.burg[next] || cells.pop[next]) cellsArray.push(next); - used[next] = 1; - - cells.c[next].forEach(neibCellId => { - const cost = rand(1, 10); - const totalPriority = priority + cost; - if (totalPriority > power) return; - - if (!costs[neibCellId] || totalPriority < costs[neibCellId]) { - costs[neibCellId] = totalPriority; - queue.push(neibCellId, totalPriority); - } - }); - } - - const type = rw({Famine: 5, Dearth: 1, Drought: 3, Earthquake: 3, Tornadoes: 1, Wildfires: 1}); - const name = getAdjective(burg.name) + " " + type; - zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch5)"}); - } - - function addEruption() { - const volcano = byId("markers").querySelector("use[data-id='#marker_volcano']"); - if (!volcano) return; - - const x = +volcano.dataset.x, - y = +volcano.dataset.y, - cell = findCell(x, y); - const id = volcano.id; - const note = notes.filter(n => n.id === id); - - if (note[0]) note[0].legend = note[0].legend.replace("Active volcano", "Erupting volcano"); - const name = note[0] ? note[0].name.replace(" Volcano", "") + " Eruption" : "Volcano Eruption"; - - const cellsArray = []; - const queue = [cell]; - const power = rand(10, 30); - - while (queue.length) { - const q = P(0.5) ? queue.shift() : queue.pop(); - cellsArray.push(q); - if (cellsArray.length > power) break; - cells.c[q].forEach(e => { - if (used[e] || cells.h[e] < 20) return; - used[e] = 1; - queue.push(e); - }); - } - - zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch7)"}); - } - - function addAvalanche() { - const roads = cells.i.filter(i => !used[i] && cells.road[i] && cells.h[i] >= 70); - if (!roads.length) return; - - const cell = +ra(roads); - const cellsArray = []; - const queue = [cell]; - const power = rand(3, 15); - - while (queue.length) { - const q = P(0.3) ? queue.shift() : queue.pop(); - cellsArray.push(q); - if (cellsArray.length > power) break; - cells.c[q].forEach(e => { - if (used[e] || cells.h[e] < 65) return; - used[e] = 1; - queue.push(e); - }); - } - - const proper = getAdjective(Names.getCultureShort(cells.culture[cell])); - const name = proper + " Avalanche"; - zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch5)"}); - } - - function addFault() { - const elevated = cells.i.filter(i => !used[i] && cells.h[i] > 50 && cells.h[i] < 70); - if (!elevated.length) return; - - const cell = ra(elevated); - const cellsArray = []; - const queue = [cell]; - const power = rand(3, 15); - - while (queue.length) { - const q = queue.pop(); - if (cells.h[q] >= 20) cellsArray.push(q); - if (cellsArray.length > power) break; - cells.c[q].forEach(e => { - if (used[e] || cells.r[e]) return; - used[e] = 1; - queue.push(e); - }); - } - - const proper = getAdjective(Names.getCultureShort(cells.culture[cell])); - const name = proper + " Fault"; - zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch2)"}); - } - - function addFlood() { - const fl = cells.fl.filter(fl => fl), - meanFlux = d3.mean(fl), - maxFlux = d3.max(fl), - flux = (maxFlux - meanFlux) / 2 + meanFlux; - const rivers = cells.i.filter( - i => !used[i] && cells.h[i] < 50 && cells.r[i] && cells.fl[i] > flux && cells.burg[i] - ); - if (!rivers.length) return; - - const cell = +ra(rivers), - river = cells.r[cell]; - const cellsArray = []; - const queue = [cell]; - const power = rand(5, 30); - - while (queue.length) { - const q = queue.pop(); - cellsArray.push(q); - if (cellsArray.length > power) break; - - cells.c[q].forEach(e => { - if (used[e] || cells.h[e] < 20 || cells.r[e] !== river || cells.h[e] > 50 || cells.fl[e] < meanFlux) return; - used[e] = 1; - queue.push(e); - }); - } - - const name = getAdjective(burgs[cells.burg[cell]].name) + " Flood"; - zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch13)"}); - } - - function addTsunami() { - const coastal = cells.i.filter(i => !used[i] && cells.t[i] === -1 && pack.features[cells.f[i]].type !== "lake"); - if (!coastal.length) return; - - const cell = +ra(coastal); - const cellsArray = []; - const queue = [cell]; - const power = rand(10, 30); - - while (queue.length) { - const q = queue.shift(); - if (cells.t[q] === 1) cellsArray.push(q); - if (cellsArray.length > power) break; - - cells.c[q].forEach(e => { - if (used[e]) return; - if (cells.t[e] > 2) return; - if (pack.features[cells.f[e]].type === "lake") return; - used[e] = 1; - queue.push(e); - }); - } - - const proper = getAdjective(Names.getCultureShort(cells.culture[cell])); - const name = proper + " Tsunami"; - zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch13)"}); - } - - function drawZones() { - zones - .selectAll("g") - .data(zonesData) - .enter() - .append("g") - .attr("id", (d, i) => "zone" + i) - .attr("data-description", d => d.name) - .attr("data-type", d => d.type) - .attr("data-cells", d => d.cells.join(",")) - .attr("fill", d => d.fill) - .selectAll("polygon") - .data(d => d.cells) - .enter() - .append("polygon") - .attr("points", d => getPackPolygon(d)) - .attr("id", function (d) { - return this.parentNode.id + "_" + d; - }); - } - - TIME && console.timeEnd("addZones"); -} - -// show map stats on generation complete -function showStatistics() { - const heightmap = byId("templateInput").value; - const isTemplate = heightmap in heightmapTemplates; - const heightmapType = isTemplate ? "template" : "precreated"; - const isRandomTemplate = isTemplate && !locked("template") ? "random " : ""; - - const stats = ` Seed: ${seed} - Canvas size: ${graphWidth}x${graphHeight} px - Heightmap: ${heightmap} (${isRandomTemplate}${heightmapType}) - Points: ${grid.points.length} - Cells: ${pack.cells.i.length} - Map size: ${mapSizeOutput.value}% - States: ${pack.states.length - 1} - Provinces: ${pack.provinces.length - 1} - Burgs: ${pack.burgs.length - 1} - Religions: ${pack.religions.length - 1} - Culture set: ${culturesSet.selectedOptions[0].innerText} - Cultures: ${pack.cultures.length - 1}`; - - mapId = Date.now(); // unique map id is it's creation date number - mapHistory.push({seed, width: graphWidth, height: graphHeight, template: heightmap, created: mapId}); - INFO && console.log(stats); -} - -const regenerateMap = debounce(async function (options) { - WARN && console.warn("Generate new random map"); - - const cellsDesired = +byId("pointsInput").dataset.cells; - const shouldShowLoading = cellsDesired > 10000; - shouldShowLoading && showLoading(); - - closeDialogs("#worldConfigurator, #options3d"); - customization = 0; - Zoom.reset(1000); - undraw(); - await generate(options); - restoreLayers(); - if (ThreeD.options.isOn) ThreeD.redraw(); - if ($("#worldConfigurator").is(":visible")) editWorld(); - - shouldShowLoading && hideLoading(); - clearMainTip(); -}, 250); - -// clear the map -function undraw() { - viewbox.selectAll("path, circle, polygon, line, text, use, #zones > g, #armies > g, #ruler > g").remove(); - document - .getElementById("deftemp") - .querySelectorAll("path, clipPath, svg") - .forEach(el => el.remove()); - byId("coas").innerHTML = ""; // remove auto-generated emblems - notes = []; - rulers = new Rulers(); - unfog(); -} diff --git a/src/modules/activeZooming.js b/src/modules/activeZooming.js index aa093928..a0c2e969 100644 --- a/src/modules/activeZooming.js +++ b/src/modules/activeZooming.js @@ -94,3 +94,18 @@ export function invokeActiveZooming() { ruler.selectAll("text").attr("font-size", size); } } + +async function renderGroupCOAs(g) { + const [group, type] = + g.id === "burgEmblems" + ? [pack.burgs, "burg"] + : g.id === "provinceEmblems" + ? [pack.provinces, "province"] + : [pack.states, "state"]; + for (let use of g.children) { + const i = +use.dataset.i; + const id = type + "COA" + i; + COArenderer.trigger(id, group[i].coa); + use.setAttribute("href", "#" + id); + } +} diff --git a/src/modules/biomes.js b/src/modules/biomes.js index 045e0310..3cba2e77 100644 --- a/src/modules/biomes.js +++ b/src/modules/biomes.js @@ -1,3 +1,7 @@ +import {TIME} from "config/logging"; +import {isLand} from "utils/graphUtils"; +import {rn} from "utils/numberUtils"; + window.Biomes = (function () { const getDefault = () => { const name = [ @@ -72,5 +76,50 @@ window.Biomes = (function () { return {i: d3.range(0, name.length), name, color, biomesMartix, habitability, iconsDensity, icons, cost}; }; - return {getDefault}; + function isWetLand(moisture, temperature, height) { + if (moisture > 40 && temperature > -2 && height < 25) return true; //near coast + if (moisture > 24 && temperature > -2 && height > 24 && height < 60) return true; //off coast + return false; + } + + // assign biome id for each cell + function define() { + TIME && console.time("defineBiomes"); + const {cells} = pack; + const {temp, prec} = grid.cells; + cells.biome = new Uint8Array(cells.i.length); // biomes array + + for (const i of cells.i) { + const temperature = temp[cells.g[i]]; + const height = cells.h[i]; + const moisture = height < 20 ? 0 : calculateMoisture(i); + cells.biome[i] = getId(moisture, temperature, height); + } + + function calculateMoisture(i) { + let moist = prec[cells.g[i]]; + if (cells.r[i]) moist += Math.max(cells.fl[i] / 20, 2); + + const n = cells.c[i] + .filter(isLand) + .map(c => prec[cells.g[c]]) + .concat([moist]); + return rn(4 + d3.mean(n)); + } + + TIME && console.timeEnd("defineBiomes"); + } + + // assign biome id to a cell + function getId(moisture, temperature, height) { + if (height < 20) return 0; // marine biome: all water cells + if (temperature < -5) return 11; // permafrost biome + if (isWetLand(moisture, temperature, height)) return 12; // wetland biome + + const moistureBand = Math.min((moisture / 5) | 0, 4); // [0-4] + const temperatureBand = Math.min(Math.max(20 - temperature, 0), 25); // [0-25] + return biomesData.biomesMartix[moistureBand][temperatureBand]; + } + + return {getDefault, define, getId}; })(); diff --git a/src/modules/coastline.js b/src/modules/coastline.js new file mode 100644 index 00000000..0cfa17a8 --- /dev/null +++ b/src/modules/coastline.js @@ -0,0 +1,144 @@ +import {ERROR, TIME} from "config/logging"; +import {reMarkFeatures} from "modules/markup"; +import {clipPoly} from "utils/lineUtils"; +import {round} from "utils/stringUtils"; +import {Ruler} from "modules/measurers"; + +// Detect and draw the coastline +export function drawCoastline() { + TIME && console.time("drawCoastline"); + reMarkFeatures(); + + const {cells, vertices, features} = pack; + const n = cells.i.length; + + const used = new Uint8Array(features.length); // store connected features + const largestLand = d3.scan( + features.map(f => (f.land ? f.cells : 0)), + (a, b) => b - a + ); + + const landMask = defs.select("#land"); + const waterMask = defs.select("#water"); + const lineGen = d3.line().curve(d3.curveBasisClosed); + + for (const i of cells.i) { + const startFromEdge = !i && cells.h[i] >= 20; + if (!startFromEdge && cells.t[i] !== -1 && cells.t[i] !== 1) continue; // non-edge cell + const f = cells.f[i]; + if (used[f]) continue; // already connected + if (features[f].type === "ocean") continue; // ocean cell + + const type = features[f].type === "lake" ? 1 : -1; // type value to search for + const start = findStart(i, type); + if (start === -1) continue; // cannot start here + let vchain = connectVertices(start, type); + if (features[f].type === "lake") relax(vchain, 1.2); + used[f] = 1; + let points = clipPoly( + vchain.map(v => vertices.p[v]), + 1 + ); + const area = d3.polygonArea(points); // area with lakes/islands + if (area > 0 && features[f].type === "lake") { + points = points.reverse(); + vchain = vchain.reverse(); + } + + features[f].area = Math.abs(area); + features[f].vertices = vchain; + + const path = round(lineGen(points)); + if (features[f].type === "lake") { + landMask + .append("path") + .attr("d", path) + .attr("fill", "black") + .attr("id", "land_" + f); + // waterMask.append("path").attr("d", path).attr("fill", "white").attr("id", "water_"+id); // uncomment to show over lakes + lakes + .select("#freshwater") + .append("path") + .attr("d", path) + .attr("id", "lake_" + f) + .attr("data-f", f); // draw the lake + } else { + landMask + .append("path") + .attr("d", path) + .attr("fill", "white") + .attr("id", "land_" + f); + waterMask + .append("path") + .attr("d", path) + .attr("fill", "black") + .attr("id", "water_" + f); + const g = features[f].group === "lake_island" ? "lake_island" : "sea_island"; + coastline + .select("#" + g) + .append("path") + .attr("d", path) + .attr("id", "island_" + f) + .attr("data-f", f); // draw the coastline + } + + // draw ruler to cover the biggest land piece + if (f === largestLand) { + const from = points[d3.scan(points, (a, b) => a[0] - b[0])]; + const to = points[d3.scan(points, (a, b) => b[0] - a[0])]; + rulers.create(Ruler, [from, to]); + } + } + + // find cell vertex to start path detection + function findStart(i, t) { + if (t === -1 && cells.b[i]) return cells.v[i].find(v => vertices.c[v].some(c => c >= n)); // map border cell + const filtered = cells.c[i].filter(c => cells.t[c] === t); + const index = cells.c[i].indexOf(d3.min(filtered)); + return index === -1 ? index : cells.v[i][index]; + } + + // connect vertices to chain + function connectVertices(start, t) { + const chain = []; // vertices chain to form a path + for (let i = 0, current = start; i === 0 || (current !== start && i < 50000); i++) { + const prev = chain[chain.length - 1]; // previous vertex in chain + chain.push(current); // add current vertex to sequence + const c = vertices.c[current]; // cells adjacent to vertex + const v = vertices.v[current]; // neighboring vertices + const c0 = c[0] >= n || cells.t[c[0]] === t; + const c1 = c[1] >= n || cells.t[c[1]] === t; + const c2 = c[2] >= n || cells.t[c[2]] === t; + if (v[0] !== prev && c0 !== c1) current = v[0]; + else if (v[1] !== prev && c1 !== c2) current = v[1]; + else if (v[2] !== prev && c0 !== c2) current = v[2]; + if (current === chain[chain.length - 1]) { + ERROR && console.error("Next vertex is not found"); + break; + } + } + return chain; + } + + // move vertices that are too close to already added ones + function relax(vchain, r) { + const p = vertices.p, + tree = d3.quadtree(); + + for (let i = 0; i < vchain.length; i++) { + const v = vchain[i]; + let [x, y] = [p[v][0], p[v][1]]; + if (i && vchain[i + 1] && tree.find(x, y, r) !== undefined) { + const v1 = vchain[i - 1]; + const v2 = vchain[i + 1]; + const [x1, y1] = [p[v1][0], p[v1][1]]; + const [x2, y2] = [p[v2][0], p[v2][1]]; + [x, y] = [(x1 + x2) / 2, (y1 + y2) / 2]; + p[v] = [x, y]; + } + tree.add([x, y]); + } + } + + TIME && console.timeEnd("drawCoastline"); +} diff --git a/src/modules/coordinates.js b/src/modules/coordinates.js new file mode 100644 index 00000000..5bb5318a --- /dev/null +++ b/src/modules/coordinates.js @@ -0,0 +1,75 @@ +import {byId} from "utils/shorthands"; +import {gauss, P} from "utils/probabilityUtils"; +import {locked} from "scripts/options/lock"; +import {rn} from "utils/numberUtils"; + +// define map size and position based on template and random factor +export function defineMapSize() { + const [size, latitude] = getSizeAndLatitude(); + const randomize = new URL(window.location.href).searchParams.get("options") === "default"; // ignore stored options + if (randomize || !locked("mapSize")) mapSizeOutput.value = mapSizeInput.value = rn(size); + if (randomize || !locked("latitude")) latitudeOutput.value = latitudeInput.value = rn(latitude); + + function getSizeAndLatitude() { + const template = byId("templateInput").value; // heightmap template + + if (template === "africa-centric") return [45, 53]; + if (template === "arabia") return [20, 35]; + if (template === "atlantics") return [42, 23]; + if (template === "britain") return [7, 20]; + if (template === "caribbean") return [15, 40]; + if (template === "east-asia") return [11, 28]; + if (template === "eurasia") return [38, 19]; + if (template === "europe") return [20, 16]; + if (template === "europe-accented") return [14, 22]; + if (template === "europe-and-central-asia") return [25, 10]; + if (template === "europe-central") return [11, 22]; + if (template === "europe-north") return [7, 18]; + if (template === "greenland") return [22, 7]; + if (template === "hellenica") return [8, 27]; + if (template === "iceland") return [2, 15]; + if (template === "indian-ocean") return [45, 55]; + if (template === "mediterranean-sea") return [10, 29]; + if (template === "middle-east") return [8, 31]; + if (template === "north-america") return [37, 17]; + if (template === "us-centric") return [66, 27]; + if (template === "us-mainland") return [16, 30]; + if (template === "world") return [78, 27]; + if (template === "world-from-pacific") return [75, 32]; + + const part = grid.features.some(f => f.land && f.border); // if land goes over map borders + const max = part ? 80 : 100; // max size + const lat = () => gauss(P(0.5) ? 40 : 60, 15, 25, 75); // latitude shift + + if (!part) { + if (template === "Pangea") return [100, 50]; + if (template === "Shattered" && P(0.7)) return [100, 50]; + if (template === "Continents" && P(0.5)) return [100, 50]; + if (template === "Archipelago" && P(0.35)) return [100, 50]; + if (template === "High Island" && P(0.25)) return [100, 50]; + if (template === "Low Island" && P(0.1)) return [100, 50]; + } + + if (template === "Pangea") return [gauss(70, 20, 30, max), lat()]; + if (template === "Volcano") return [gauss(20, 20, 10, max), lat()]; + if (template === "Mediterranean") return [gauss(25, 30, 15, 80), lat()]; + if (template === "Peninsula") return [gauss(15, 15, 5, 80), lat()]; + if (template === "Isthmus") return [gauss(15, 20, 3, 80), lat()]; + if (template === "Atoll") return [gauss(5, 10, 2, max), lat()]; + + return [gauss(30, 20, 15, max), lat()]; // Continents, Archipelago, High Island, Low Island + } +} + +// calculate map position on globe +export function calculateMapCoordinates() { + const size = +byId("mapSizeOutput").value; + const latShift = +byId("latitudeOutput").value; + + const latT = rn((size / 100) * 180, 1); + const latN = rn(90 - ((180 - latT) * latShift) / 100, 1); + const latS = rn(latN - latT, 1); + + const lon = rn(Math.min(((graphWidth / graphHeight) * latT) / 2, 180)); + return {latT, latN, latS, lonT: lon * 2, lonW: -lon, lonE: lon}; +} diff --git a/src/modules/io/load.js b/src/modules/io/load.js index 4a0c7e69..1eaa3746 100644 --- a/src/modules/io/load.js +++ b/src/modules/io/load.js @@ -7,6 +7,55 @@ import {parseError} from "utils/errorUtils"; import {calculateVoronoi, findCell} from "utils/graphUtils"; import {link} from "utils/linkUtils"; import {minmax, rn} from "utils/numberUtils"; +import {regenerateMap} from "scripts/generation"; +import {reMarkFeatures} from "modules/markup"; + +// add drag to upload logic, pull request from @evyatron +export function addDragToUpload() { + document.addEventListener("dragover", function (e) { + e.stopPropagation(); + e.preventDefault(); + byId("mapOverlay").style.display = null; + }); + + document.addEventListener("dragleave", function (e) { + byId("mapOverlay").style.display = "none"; + }); + + document.addEventListener("drop", function (e) { + e.stopPropagation(); + e.preventDefault(); + + const overlay = byId("mapOverlay"); + overlay.style.display = "none"; + if (e.dataTransfer.items == null || e.dataTransfer.items.length !== 1) return; // no files or more than one + const file = e.dataTransfer.items[0].getAsFile(); + if (file.name.indexOf(".map") == -1) { + // not a .map file + alertMessage.innerHTML = "Please upload a .map file you have previously downloaded"; + $("#alert").dialog({ + resizable: false, + title: "Invalid file format", + position: {my: "center", at: "center", of: "svg"}, + buttons: { + Close: function () { + $(this).dialog("close"); + } + } + }); + return; + } + + // all good - show uploading text and load the map + overlay.style.display = null; + overlay.innerHTML = "Uploading..."; + if (closeDialogs) closeDialogs(); + uploadMap(file, () => { + overlay.style.display = "none"; + overlay.innerHTML = "Drop a .map file to open"; + }); + }); +} function quickLoad() { ldb.get("lastMap", blob => { @@ -83,7 +132,7 @@ function loadMapPrompt(blob) { } } -function loadMapFromURL(maplink, random) { +export function loadMapFromURL(maplink, random) { const URL = decodeURIComponent(maplink); fetch(URL, {method: "GET", mode: "cors"}) diff --git a/src/modules/lakes.js b/src/modules/lakes.js index 4c352519..bdf7eb7c 100644 --- a/src/modules/lakes.js +++ b/src/modules/lakes.js @@ -1,5 +1,7 @@ +import {TIME} from "config/logging"; import {rn} from "utils/numberUtils"; import {aleaPRNG} from "scripts/aleaPRNG"; +import {byId} from "utils/shorthands"; window.Lakes = (function () { const setClimateData = function (h) { @@ -150,5 +152,113 @@ window.Lakes = (function () { return "freshwater"; } - return {setClimateData, cleanupLakeData, prepareLakeData, defineGroup, generateName, getName, getShoreline}; + function addLakesInDeepDepressions() { + TIME && console.time("addLakesInDeepDepressions"); + const {cells, features} = grid; + const {c, h, b} = cells; + const ELEVATION_LIMIT = +byId("lakeElevationLimitOutput").value; + if (ELEVATION_LIMIT === 80) return; + + for (const i of cells.i) { + if (b[i] || h[i] < 20) continue; + + const minHeight = d3.min(c[i].map(c => h[c])); + if (h[i] > minHeight) continue; + + let deep = true; + const threshold = h[i] + ELEVATION_LIMIT; + const queue = [i]; + const checked = []; + checked[i] = true; + + // check if elevated cell can potentially pour to water + while (deep && queue.length) { + const q = queue.pop(); + + for (const n of c[q]) { + if (checked[n]) continue; + if (h[n] >= threshold) continue; + if (h[n] < 20) { + deep = false; + break; + } + + checked[n] = true; + queue.push(n); + } + } + + // if not, add a lake + if (deep) { + const lakeCells = [i].concat(c[i].filter(n => h[n] === h[i])); + addLake(lakeCells); + } + } + + function addLake(lakeCells) { + const f = features.length; + + lakeCells.forEach(i => { + cells.h[i] = 19; + cells.t[i] = -1; + cells.f[i] = f; + c[i].forEach(n => !lakeCells.includes(n) && (cells.t[c] = 1)); + }); + + features.push({i: f, land: false, border: false, type: "lake"}); + } + + TIME && console.timeEnd("addLakesInDeepDepressions"); + } + + // near sea lakes usually get a lot of water inflow, most of them should brake threshold and flow out to sea (see Ancylus Lake) + function openNearSeaLakes() { + if (byId("templateInput").value === "Atoll") return; // no need for Atolls + + const cells = grid.cells; + const features = grid.features; + if (!features.find(f => f.type === "lake")) return; // no lakes + TIME && console.time("openLakes"); + const LIMIT = 22; // max height that can be breached by water + + for (const i of cells.i) { + const lake = cells.f[i]; + if (features[lake].type !== "lake") continue; // not a lake cell + + check_neighbours: for (const c of cells.c[i]) { + if (cells.t[c] !== 1 || cells.h[c] > LIMIT) continue; // water cannot brake this + + for (const n of cells.c[c]) { + const ocean = cells.f[n]; + if (features[ocean].type !== "ocean") continue; // not an ocean + removeLake(c, lake, ocean); + break check_neighbours; + } + } + } + + function removeLake(threshold, lake, ocean) { + cells.h[threshold] = 19; + cells.t[threshold] = -1; + cells.f[threshold] = ocean; + cells.c[threshold].forEach(function (c) { + if (cells.h[c] >= 20) cells.t[c] = 1; // mark as coastline + }); + features[lake].type = "ocean"; // mark former lake as ocean + } + + TIME && console.timeEnd("openLakes"); + } + + return { + setClimateData, + cleanupLakeData, + prepareLakeData, + defineGroup, + generateName, + getName, + getShoreline, + addLakesInDeepDepressions, + openNearSeaLakes + }; })(); diff --git a/src/modules/markup.js b/src/modules/markup.js new file mode 100644 index 00000000..ea9e5a46 --- /dev/null +++ b/src/modules/markup.js @@ -0,0 +1,143 @@ +import {TIME} from "config/logging"; +import {aleaPRNG} from "scripts/aleaPRNG"; + +// Mark features (ocean, lakes, islands) and calculate distance field +export function markFeatures() { + TIME && console.time("markFeatures"); + Math.random = aleaPRNG(seed); // get the same result on heightmap edit in Erase mode + + const cells = grid.cells; + const heights = grid.cells.h; + cells.f = new Uint16Array(cells.i.length); // cell feature number + cells.t = new Int8Array(cells.i.length); // cell type: 1 = land coast; -1 = water near coast + grid.features = [0]; + + for (let i = 1, queue = [0]; queue[0] !== -1; i++) { + cells.f[queue[0]] = i; // feature number + const land = heights[queue[0]] >= 20; + let border = false; // true if feature touches map border + + while (queue.length) { + const q = queue.pop(); + if (cells.b[q]) border = true; + + cells.c[q].forEach(c => { + const cLand = heights[c] >= 20; + if (land === cLand && !cells.f[c]) { + cells.f[c] = i; + queue.push(c); + } else if (land && !cLand) { + cells.t[q] = 1; + cells.t[c] = -1; + } + }); + } + const type = land ? "island" : border ? "ocean" : "lake"; + grid.features.push({i, land, border, type}); + + queue[0] = cells.f.findIndex(f => !f); // find unmarked cell + } + + TIME && console.timeEnd("markFeatures"); +} + +export function markupGridOcean() { + TIME && console.time("markupGridOcean"); + markup(grid.cells, -2, -1, -10); + TIME && console.timeEnd("markupGridOcean"); +} + +// Calculate cell-distance to coast for every cell +export function markup(cells, start, increment, limit) { + for (let t = start, count = Infinity; count > 0 && t > limit; t += increment) { + count = 0; + const prevT = t - increment; + for (let i = 0; i < cells.i.length; i++) { + if (cells.t[i] !== prevT) continue; + + for (const c of cells.c[i]) { + if (cells.t[c]) continue; + cells.t[c] = t; + count++; + } + } + } +} + +// Re-mark features (ocean, lakes, islands) +export function reMarkFeatures() { + TIME && console.time("reMarkFeatures"); + const {cells} = pack; + const features = [0]; + + cells.f = new Uint16Array(cells.i.length); // cell feature number + cells.t = new Int8Array(cells.i.length); // cell type: 1 = land along coast; -1 = water along coast; + cells.haven = cells.i.length < 65535 ? new Uint16Array(cells.i.length) : new Uint32Array(cells.i.length); // cell haven (opposite water cell); + cells.harbor = new Uint8Array(cells.i.length); // cell harbor (number of adjacent water cells); + + const defineHaven = i => { + const water = cells.c[i].filter(c => cells.h[c] < 20); + const dist2 = water.map(c => (cells.p[i][0] - cells.p[c][0]) ** 2 + (cells.p[i][1] - cells.p[c][1]) ** 2); + const closest = water[dist2.indexOf(Math.min.apply(Math, dist2))]; + + cells.haven[i] = closest; + cells.harbor[i] = water.length; + }; + + if (!cells.i.length) return; // no cells -> there is nothing to do + for (let i = 1, queue = [0]; queue[0] !== -1; i++) { + const start = queue[0]; // first cell + cells.f[start] = i; // assign feature number + const land = cells.h[start] >= 20; + let border = false; // true if feature touches map border + let cellNumber = 1; // to count cells number in a feature + + while (queue.length) { + const q = queue.pop(); + if (cells.b[q]) border = true; + cells.c[q].forEach(function (e) { + const eLand = cells.h[e] >= 20; + if (land && !eLand) { + cells.t[q] = 1; + cells.t[e] = -1; + if (!cells.haven[q]) defineHaven(q); + } else if (land && eLand) { + if (!cells.t[e] && cells.t[q] === 1) cells.t[e] = 2; + else if (!cells.t[q] && cells.t[e] === 1) cells.t[q] = 2; + } + if (!cells.f[e] && land === eLand) { + queue.push(e); + cells.f[e] = i; + cellNumber++; + } + }); + } + + const type = land ? "island" : border ? "ocean" : "lake"; + let group; + if (type === "ocean") group = defineOceanGroup(cellNumber); + else if (type === "island") group = defineIslandGroup(start, cellNumber); + features.push({i, land, border, type, cells: cellNumber, firstCell: start, group}); + queue[0] = cells.f.findIndex(f => !f); // find unmarked cell + } + + // markupPackLand + markup(pack.cells, 3, 1, 0); + + function defineOceanGroup(number) { + if (number > grid.cells.i.length / 25) return "ocean"; + if (number > grid.cells.i.length / 100) return "sea"; + return "gulf"; + } + + function defineIslandGroup(cell, number) { + if (cell && features[cells.f[cell - 1]].type === "lake") return "lake_island"; + if (number > grid.cells.i.length / 10) return "continent"; + if (number > grid.cells.i.length / 1000) return "island"; + return "isle"; + } + + pack.features = features; + + TIME && console.timeEnd("reMarkFeatures"); +} diff --git a/src/modules/precipitation.js b/src/modules/precipitation.js new file mode 100644 index 00000000..f2354d91 --- /dev/null +++ b/src/modules/precipitation.js @@ -0,0 +1,163 @@ +import {TIME} from "config/logging"; +import {minmax} from "utils/numberUtils"; +import {rand} from "utils/probabilityUtils"; + +// simplest precipitation model +export function generatePrecipitation() { + TIME && console.time("generatePrecipitation"); + prec.selectAll("*").remove(); + const {cells, cellsX, cellsY} = grid; + cells.prec = new Uint8Array(cells.i.length); // precipitation array + + const cellsNumberModifier = (pointsInput.dataset.cells / 10000) ** 0.25; + const precInputModifier = precInput.value / 100; + const modifier = cellsNumberModifier * precInputModifier; + + const westerly = []; + const easterly = []; + let southerly = 0; + let northerly = 0; + + // precipitation modifier per latitude band + // x4 = 0-5 latitude: wet through the year (rising zone) + // x2 = 5-20 latitude: wet summer (rising zone), dry winter (sinking zone) + // x1 = 20-30 latitude: dry all year (sinking zone) + // x2 = 30-50 latitude: wet winter (rising zone), dry summer (sinking zone) + // x3 = 50-60 latitude: wet all year (rising zone) + // x2 = 60-70 latitude: wet summer (rising zone), dry winter (sinking zone) + // x1 = 70-85 latitude: dry all year (sinking zone) + // x0.5 = 85-90 latitude: dry all year (sinking zone) + const latitudeModifier = [4, 2, 2, 2, 1, 1, 2, 2, 2, 2, 3, 3, 2, 2, 1, 1, 1, 0.5]; + const MAX_PASSABLE_ELEVATION = 85; + + // define wind directions based on cells latitude and prevailing winds there + d3.range(0, cells.i.length, cellsX).forEach(function (c, i) { + const lat = mapCoordinates.latN - (i / cellsY) * mapCoordinates.latT; + const latBand = ((Math.abs(lat) - 1) / 5) | 0; + const latMod = latitudeModifier[latBand]; + const windTier = (Math.abs(lat - 89) / 30) | 0; // 30d tiers from 0 to 5 from N to S + const {isWest, isEast, isNorth, isSouth} = getWindDirections(windTier); + + if (isWest) westerly.push([c, latMod, windTier]); + if (isEast) easterly.push([c + cellsX - 1, latMod, windTier]); + if (isNorth) northerly++; + if (isSouth) southerly++; + }); + + // distribute winds by direction + if (westerly.length) passWind(westerly, 120 * modifier, 1, cellsX); + if (easterly.length) passWind(easterly, 120 * modifier, -1, cellsX); + + const vertT = southerly + northerly; + if (northerly) { + const bandN = ((Math.abs(mapCoordinates.latN) - 1) / 5) | 0; + const latModN = mapCoordinates.latT > 60 ? d3.mean(latitudeModifier) : latitudeModifier[bandN]; + const maxPrecN = (northerly / vertT) * 60 * modifier * latModN; + passWind(d3.range(0, cellsX, 1), maxPrecN, cellsX, cellsY); + } + + if (southerly) { + const bandS = ((Math.abs(mapCoordinates.latS) - 1) / 5) | 0; + const latModS = mapCoordinates.latT > 60 ? d3.mean(latitudeModifier) : latitudeModifier[bandS]; + const maxPrecS = (southerly / vertT) * 60 * modifier * latModS; + passWind(d3.range(cells.i.length - cellsX, cells.i.length, 1), maxPrecS, -cellsX, cellsY); + } + + function getWindDirections(tier) { + const angle = options.winds[tier]; + + const isWest = angle > 40 && angle < 140; + const isEast = angle > 220 && angle < 320; + const isNorth = angle > 100 && angle < 260; + const isSouth = angle > 280 || angle < 80; + + return {isWest, isEast, isNorth, isSouth}; + } + + function passWind(source, maxPrec, next, steps) { + const maxPrecInit = maxPrec; + + for (let first of source) { + if (first[0]) { + maxPrec = Math.min(maxPrecInit * first[1], 255); + first = first[0]; + } + + let humidity = maxPrec - cells.h[first]; // initial water amount + if (humidity <= 0) continue; // if first cell in row is too elevated consider wind dry + + for (let s = 0, current = first; s < steps; s++, current += next) { + if (cells.temp[current] < -5) continue; // no flux in permafrost + + if (cells.h[current] < 20) { + // water cell + if (cells.h[current + next] >= 20) { + cells.prec[current + next] += Math.max(humidity / rand(10, 20), 1); // coastal precipitation + } else { + humidity = Math.min(humidity + 5 * modifier, maxPrec); // wind gets more humidity passing water cell + cells.prec[current] += 5 * modifier; // water cells precipitation (need to correctly pour water through lakes) + } + continue; + } + + // land cell + const isPassable = cells.h[current + next] <= MAX_PASSABLE_ELEVATION; + const precipitation = isPassable ? getPrecipitation(humidity, current, next) : humidity; + cells.prec[current] += precipitation; + const evaporation = precipitation > 1.5 ? 1 : 0; // some humidity evaporates back to the atmosphere + humidity = isPassable ? minmax(humidity - precipitation + evaporation, 0, maxPrec) : 0; + } + } + } + + function getPrecipitation(humidity, i, n) { + const normalLoss = Math.max(humidity / (10 * modifier), 1); // precipitation in normal conditions + const diff = Math.max(cells.h[i + n] - cells.h[i], 0); // difference in height + const mod = (cells.h[i + n] / 70) ** 2; // 50 stands for hills, 70 for mountains + return minmax(normalLoss + diff * mod, 1, humidity); + } + + void (function drawWindDirection() { + const wind = prec.append("g").attr("id", "wind"); + + d3.range(0, 6).forEach(function (t) { + if (westerly.length > 1) { + const west = westerly.filter(w => w[2] === t); + if (west && west.length > 3) { + const from = west[0][0], + to = west[west.length - 1][0]; + const y = (grid.points[from][1] + grid.points[to][1]) / 2; + wind.append("text").attr("x", 20).attr("y", y).text("\u21C9"); + } + } + if (easterly.length > 1) { + const east = easterly.filter(w => w[2] === t); + if (east && east.length > 3) { + const from = east[0][0], + to = east[east.length - 1][0]; + const y = (grid.points[from][1] + grid.points[to][1]) / 2; + wind + .append("text") + .attr("x", graphWidth - 52) + .attr("y", y) + .text("\u21C7"); + } + } + }); + + if (northerly) + wind + .append("text") + .attr("x", graphWidth / 2) + .attr("y", 42) + .text("\u21CA"); + if (southerly) + wind + .append("text") + .attr("x", graphWidth / 2) + .attr("y", graphHeight - 20) + .text("\u21C8"); + })(); + + TIME && console.timeEnd("generatePrecipitation"); +} diff --git a/src/modules/submap.js b/src/modules/submap.js index 8bb12bd5..bdde2efe 100644 --- a/src/modules/submap.js +++ b/src/modules/submap.js @@ -114,8 +114,8 @@ window.Submap = (function () { // Warning: addLakesInDeepDepressions can be very slow! if (options.addLakesInDepressions) { - addLakesInDeepDepressions(); - openNearSeaLakes(); + Lakes.addLakesInDeepDepressions(); + Lakes.openNearSeaLakes(); } OceanLayers(); @@ -214,7 +214,7 @@ window.Submap = (function () { // biome calculation based on (resampled) grid.cells.temp and prec // it's safe to recalculate. stage("Regenerating Biome."); - defineBiomes(); + Biomes.define(); // recalculate suitability and population // TODO: normalize according to the base-map rankCells(); diff --git a/src/modules/temperature.js b/src/modules/temperature.js new file mode 100644 index 00000000..d5698fa6 --- /dev/null +++ b/src/modules/temperature.js @@ -0,0 +1,33 @@ +import {TIME} from "config/logging"; +import {minmax, rn} from "utils/numberUtils"; + +// temperature model +export function calculateTemperatures() { + TIME && console.time("calculateTemperatures"); + const cells = grid.cells; + cells.temp = new Int8Array(cells.i.length); // temperature array + + const tEq = +temperatureEquatorInput.value; + const tPole = +temperaturePoleInput.value; + const tDelta = tEq - tPole; + const int = d3.easePolyInOut.exponent(0.5); // interpolation function + + d3.range(0, cells.i.length, grid.cellsX).forEach(function (r) { + const y = grid.points[r][1]; + const lat = Math.abs(mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT); // [0; 90] + const initTemp = tEq - int(lat / 90) * tDelta; + for (let i = r; i < r + grid.cellsX; i++) { + cells.temp[i] = minmax(initTemp - convertToFriendly(cells.h[i]), -128, 127); + } + }); + + // temperature decreases by 6.5 degree C per 1km + function convertToFriendly(h) { + if (h < 20) return 0; + const exponent = +heightExponentInput.value; + const height = Math.pow(h - 18, exponent); + return rn((height / 1000) * 6.5); + } + + TIME && console.timeEnd("calculateTemperatures"); +} diff --git a/src/modules/ui/biomes-editor.js b/src/modules/ui/biomes-editor.js index 56113fa0..f87992f3 100644 --- a/src/modules/ui/biomes-editor.js +++ b/src/modules/ui/biomes-editor.js @@ -472,7 +472,7 @@ export function editBiomes() { function restoreInitialBiomes() { biomesData = applyDefaultBiomesSystem(); - defineBiomes(); + Biomes.define(); drawBiomes(); recalculatePopulation(); refreshBiomesEditor(); diff --git a/src/modules/ui/heightmap-editor.js b/src/modules/ui/heightmap-editor.js index 03c7932c..df6cd89e 100644 --- a/src/modules/ui/heightmap-editor.js +++ b/src/modules/ui/heightmap-editor.js @@ -14,6 +14,7 @@ import {restoreDefaultEvents} from "scripts/events"; import {prompt} from "scripts/prompt"; import {clearMainTip, showMainTip, tip} from "scripts/tooltips"; import {aleaPRNG} from "scripts/aleaPRNG"; +import {undraw} from "scripts/generation"; export function editHeightmap(options) { const {mode, tool} = options || {}; @@ -203,8 +204,8 @@ export function editHeightmap(options) { markFeatures(); markupGridOcean(); if (erosionAllowed) { - addLakesInDeepDepressions(); - openNearSeaLakes(); + Lakes.addLakesInDeepDepressions(); + Lakes.openNearSeaLakes(); } OceanLayers(); calculateTemperatures(); @@ -224,7 +225,7 @@ export function editHeightmap(options) { drawRivers(); Lakes.defineGroup(); - defineBiomes(); + Biomes.define(); rankCells(); Cultures.generate(); Cultures.expand(); @@ -358,7 +359,7 @@ export function editHeightmap(options) { // check biome pack.cells.biome[i] = - isLand && biome[g] ? biome[g] : getBiomeId(grid.cells.prec[g], grid.cells.temp[g], pack.cells.h[i]); + isLand && biome[g] ? biome[g] : Biomes.getId(grid.cells.prec[g], grid.cells.temp[g], pack.cells.h[i]); // rivers data if (!erosionAllowed) { diff --git a/src/modules/ui/options.js b/src/modules/ui/options.js index 602e732f..40e7f681 100644 --- a/src/modules/ui/options.js +++ b/src/modules/ui/options.js @@ -7,6 +7,7 @@ import {applyDropdownOption} from "utils/nodeUtils"; import {minmax, rn} from "utils/numberUtils"; import {gauss, P, rand, rw} from "utils/probabilityUtils"; import {byId, stored} from "utils/shorthands"; +import {regenerateMap} from "scripts/generation"; $("#optionsContainer").draggable({handle: ".drag-trigger", snap: "svg", snapMode: "both"}); $("#exitCustomization").draggable({handle: "div"}); diff --git a/src/modules/ui/submap.js b/src/modules/ui/submap.js index 36180a7a..3fa6a9e3 100644 --- a/src/modules/ui/submap.js +++ b/src/modules/ui/submap.js @@ -4,6 +4,7 @@ import {parseError} from "utils/errorUtils"; import {rn, minmax} from "utils/numberUtils"; import {debounce} from "utils/functionUtils"; import {restoreLayers} from "layers"; +import {undraw} from "scripts/generation"; window.UISubmap = (function () { byId("submapPointsInput").addEventListener("input", function () { diff --git a/src/modules/ui/world-configurator.js b/src/modules/ui/world-configurator.js index 9c025848..2f5457e7 100644 --- a/src/modules/ui/world-configurator.js +++ b/src/modules/ui/world-configurator.js @@ -62,7 +62,7 @@ export function editWorld() { Lakes.defineGroup(); Rivers.specify(); pack.cells.h = new Float32Array(heights); - defineBiomes(); + Biomes.define(); if (layerIsOn("toggleTemp")) drawTemp(); if (layerIsOn("togglePrec")) drawPrec(); diff --git a/src/modules/zones.js b/src/modules/zones.js new file mode 100644 index 00000000..ffb624c0 --- /dev/null +++ b/src/modules/zones.js @@ -0,0 +1,451 @@ +import FlatQueue from "flatqueue"; + +import {TIME} from "config/logging"; +import {findCell, getPackPolygon} from "utils/graphUtils"; +import {getAdjective} from "utils/languageUtils"; +import {rn} from "utils/numberUtils"; +import {P, ra, rand, rw} from "utils/probabilityUtils"; +import {byId} from "utils/shorthands"; + +// generate zones +export function addZones(number = 1) { + TIME && console.time("addZones"); + const {cells, states, burgs} = pack; + const used = new Uint8Array(cells.i.length); // to store used cells + const zonesData = []; + const randomize = modifier => rn(Math.random() * modifier * number); + + for (let i = 0; i < randomize(1.8); i++) addInvasion(); // invasion of enemy lands + for (let i = 0; i < randomize(1.6); i++) addRebels(); // rebels along a state border + for (let i = 0; i < randomize(1.6); i++) addProselytism(); // proselitism of organized religion + for (let i = 0; i < randomize(1.6); i++) addCrusade(); // crusade on heresy lands + for (let i = 0; i < randomize(1.8); i++) addDisease(); // disease starting in a random city + for (let i = 0; i < randomize(1.4); i++) addDisaster(); // disaster starting in a random city + for (let i = 0; i < randomize(1.4); i++) addEruption(); // volcanic eruption aroung volcano + for (let i = 0; i < randomize(1.0); i++) addAvalanche(); // avalanche impacting highland road + for (let i = 0; i < randomize(1.4); i++) addFault(); // fault line in elevated areas + for (let i = 0; i < randomize(1.4); i++) addFlood(); // flood on river banks + for (let i = 0; i < randomize(1.2); i++) addTsunami(); // tsunami starting near coast + + drawZones(); + + function addInvasion() { + const atWar = states.filter(s => s.diplomacy && s.diplomacy.some(d => d === "Enemy")); + if (!atWar.length) return; + + const invader = ra(atWar); + const target = invader.diplomacy.findIndex(d => d === "Enemy"); + + const cell = ra( + cells.i.filter(i => cells.state[i] === target && cells.c[i].some(c => cells.state[c] === invader.i)) + ); + if (!cell) return; + + const cellsArray = [], + queue = [cell], + power = rand(5, 30); + + while (queue.length) { + const q = P(0.4) ? queue.shift() : queue.pop(); + cellsArray.push(q); + if (cellsArray.length > power) break; + + cells.c[q].forEach(e => { + if (used[e]) return; + if (cells.state[e] !== target) return; + used[e] = 1; + queue.push(e); + }); + } + + const invasion = rw({ + Invasion: 4, + Occupation: 3, + Raid: 2, + Conquest: 2, + Subjugation: 1, + Foray: 1, + Skirmishes: 1, + Incursion: 2, + Pillaging: 1, + Intervention: 1 + }); + const name = getAdjective(invader.name) + " " + invasion; + zonesData.push({name, type: "Invasion", cells: cellsArray, fill: "url(#hatch1)"}); + } + + function addRebels() { + const state = ra(states.filter(s => s.i && !s.removed && s.neighbors.some(n => n))); + if (!state) return; + + const neib = ra(state.neighbors.filter(n => n && !states[n].removed)); + if (!neib) return; + const cell = cells.i.find( + i => cells.state[i] === state.i && !state.removed && cells.c[i].some(c => cells.state[c] === neib) + ); + const cellsArray = []; + const queue = []; + if (cell) queue.push(cell); + + const power = rand(10, 30); + + while (queue.length) { + const q = queue.shift(); + cellsArray.push(q); + if (cellsArray.length > power) break; + + cells.c[q].forEach(e => { + if (used[e]) return; + if (cells.state[e] !== state.i) return; + used[e] = 1; + if (e % 4 !== 0 && !cells.c[e].some(c => cells.state[c] === neib)) return; + queue.push(e); + }); + } + + const rebels = rw({ + Rebels: 5, + Insurgents: 2, + Mutineers: 1, + Rioters: 1, + Separatists: 1, + Secessionists: 1, + Insurrection: 2, + Rebellion: 1, + Conspiracy: 2 + }); + const name = getAdjective(states[neib].name) + " " + rebels; + zonesData.push({name, type: "Rebels", cells: cellsArray, fill: "url(#hatch3)"}); + } + + function addProselytism() { + const organized = ra(pack.religions.filter(r => r.type === "Organized")); + if (!organized) return; + + const cell = ra( + cells.i.filter( + i => + cells.religion[i] && + cells.religion[i] !== organized.i && + cells.c[i].some(c => cells.religion[c] === organized.i) + ) + ); + if (!cell) return; + const target = cells.religion[cell]; + const cellsArray = [], + queue = [cell], + power = rand(10, 30); + + while (queue.length) { + const q = queue.shift(); + cellsArray.push(q); + if (cellsArray.length > power) break; + + cells.c[q].forEach(e => { + if (used[e]) return; + if (cells.religion[e] !== target) return; + if (cells.h[e] < 20) return; + used[e] = 1; + //if (e%2 !== 0 && !cells.c[e].some(c => cells.state[c] === neib)) return; + queue.push(e); + }); + } + + const name = getAdjective(organized.name.split(" ")[0]) + " Proselytism"; + zonesData.push({name, type: "Proselytism", cells: cellsArray, fill: "url(#hatch6)"}); + } + + function addCrusade() { + const heresy = ra(pack.religions.filter(r => r.type === "Heresy")); + if (!heresy) return; + + const cellsArray = cells.i.filter(i => !used[i] && cells.religion[i] === heresy.i); + if (!cellsArray.length) return; + cellsArray.forEach(i => (used[i] = 1)); + + const name = getAdjective(heresy.name.split(" ")[0]) + " Crusade"; + zonesData.push({name, type: "Crusade", cells: cellsArray, fill: "url(#hatch6)"}); + } + + function addDisease() { + const burg = ra(burgs.filter(b => !used[b.cell] && b.i && !b.removed)); // random burg + if (!burg) return; + + const cellsArray = []; + const costs = []; + const power = rand(20, 37); + + const queue = new FlatQueue(); + queue.push(burg.cell, 0); + + while (queue.length) { + const priority = queue.peekValue(); + const next = queue.pop(); + + if (cells.burg[next] || cells.pop[next]) cellsArray.push(next); + used[next] = 1; + + cells.c[next].forEach(neibCellId => { + const roadValue = cells.road[next]; + const cost = roadValue ? Math.max(10 - roadValue, 1) : 100; + const totalPriority = priority + cost; + if (totalPriority > power) return; + + if (!costs[neibCellId] || totalPriority < costs[neibCellId]) { + costs[neibCellId] = totalPriority; + queue.push(neibCellId, totalPriority); + } + }); + } + + const adjective = () => + ra(["Great", "Silent", "Severe", "Blind", "Unknown", "Loud", "Deadly", "Burning", "Bloody", "Brutal", "Fatal"]); + const animal = () => + ra([ + "Ape", + "Bear", + "Boar", + "Cat", + "Cow", + "Dog", + "Pig", + "Fox", + "Bird", + "Horse", + "Rat", + "Raven", + "Sheep", + "Spider", + "Wolf" + ]); + const color = () => + ra([ + "Golden", + "White", + "Black", + "Red", + "Pink", + "Purple", + "Blue", + "Green", + "Yellow", + "Amber", + "Orange", + "Brown", + "Grey" + ]); + + const type = rw({ + Fever: 5, + Pestilence: 2, + Flu: 2, + Pox: 2, + Smallpox: 2, + Plague: 4, + Cholera: 2, + Dropsy: 1, + Leprosy: 2 + }); + const name = rw({[color()]: 4, [animal()]: 2, [adjective()]: 1}) + " " + type; + zonesData.push({name, type: "Disease", cells: cellsArray, fill: "url(#hatch12)"}); + } + + function addDisaster() { + const burg = ra(burgs.filter(b => !used[b.cell] && b.i && !b.removed)); // random burg + if (!burg) return; + + const cellsArray = []; + const costs = []; + const power = rand(5, 25); + + const queue = new FlatQueue(); + queue.push(burg.cell, 0); + + while (queue.length) { + const priority = queue.peekValue(); + const next = queue.pop(); + + if (cells.burg[next] || cells.pop[next]) cellsArray.push(next); + used[next] = 1; + + cells.c[next].forEach(neibCellId => { + const cost = rand(1, 10); + const totalPriority = priority + cost; + if (totalPriority > power) return; + + if (!costs[neibCellId] || totalPriority < costs[neibCellId]) { + costs[neibCellId] = totalPriority; + queue.push(neibCellId, totalPriority); + } + }); + } + + const type = rw({Famine: 5, Dearth: 1, Drought: 3, Earthquake: 3, Tornadoes: 1, Wildfires: 1}); + const name = getAdjective(burg.name) + " " + type; + zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch5)"}); + } + + function addEruption() { + const volcano = byId("markers").querySelector("use[data-id='#marker_volcano']"); + if (!volcano) return; + + const x = +volcano.dataset.x, + y = +volcano.dataset.y, + cell = findCell(x, y); + const id = volcano.id; + const note = notes.filter(n => n.id === id); + + if (note[0]) note[0].legend = note[0].legend.replace("Active volcano", "Erupting volcano"); + const name = note[0] ? note[0].name.replace(" Volcano", "") + " Eruption" : "Volcano Eruption"; + + const cellsArray = []; + const queue = [cell]; + const power = rand(10, 30); + + while (queue.length) { + const q = P(0.5) ? queue.shift() : queue.pop(); + cellsArray.push(q); + if (cellsArray.length > power) break; + cells.c[q].forEach(e => { + if (used[e] || cells.h[e] < 20) return; + used[e] = 1; + queue.push(e); + }); + } + + zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch7)"}); + } + + function addAvalanche() { + const roads = cells.i.filter(i => !used[i] && cells.road[i] && cells.h[i] >= 70); + if (!roads.length) return; + + const cell = +ra(roads); + const cellsArray = []; + const queue = [cell]; + const power = rand(3, 15); + + while (queue.length) { + const q = P(0.3) ? queue.shift() : queue.pop(); + cellsArray.push(q); + if (cellsArray.length > power) break; + cells.c[q].forEach(e => { + if (used[e] || cells.h[e] < 65) return; + used[e] = 1; + queue.push(e); + }); + } + + const proper = getAdjective(Names.getCultureShort(cells.culture[cell])); + const name = proper + " Avalanche"; + zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch5)"}); + } + + function addFault() { + const elevated = cells.i.filter(i => !used[i] && cells.h[i] > 50 && cells.h[i] < 70); + if (!elevated.length) return; + + const cell = ra(elevated); + const cellsArray = []; + const queue = [cell]; + const power = rand(3, 15); + + while (queue.length) { + const q = queue.pop(); + if (cells.h[q] >= 20) cellsArray.push(q); + if (cellsArray.length > power) break; + cells.c[q].forEach(e => { + if (used[e] || cells.r[e]) return; + used[e] = 1; + queue.push(e); + }); + } + + const proper = getAdjective(Names.getCultureShort(cells.culture[cell])); + const name = proper + " Fault"; + zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch2)"}); + } + + function addFlood() { + const fl = cells.fl.filter(fl => fl), + meanFlux = d3.mean(fl), + maxFlux = d3.max(fl), + flux = (maxFlux - meanFlux) / 2 + meanFlux; + const rivers = cells.i.filter( + i => !used[i] && cells.h[i] < 50 && cells.r[i] && cells.fl[i] > flux && cells.burg[i] + ); + if (!rivers.length) return; + + const cell = +ra(rivers), + river = cells.r[cell]; + const cellsArray = []; + const queue = [cell]; + const power = rand(5, 30); + + while (queue.length) { + const q = queue.pop(); + cellsArray.push(q); + if (cellsArray.length > power) break; + + cells.c[q].forEach(e => { + if (used[e] || cells.h[e] < 20 || cells.r[e] !== river || cells.h[e] > 50 || cells.fl[e] < meanFlux) return; + used[e] = 1; + queue.push(e); + }); + } + + const name = getAdjective(burgs[cells.burg[cell]].name) + " Flood"; + zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch13)"}); + } + + function addTsunami() { + const coastal = cells.i.filter(i => !used[i] && cells.t[i] === -1 && pack.features[cells.f[i]].type !== "lake"); + if (!coastal.length) return; + + const cell = +ra(coastal); + const cellsArray = []; + const queue = [cell]; + const power = rand(10, 30); + + while (queue.length) { + const q = queue.shift(); + if (cells.t[q] === 1) cellsArray.push(q); + if (cellsArray.length > power) break; + + cells.c[q].forEach(e => { + if (used[e]) return; + if (cells.t[e] > 2) return; + if (pack.features[cells.f[e]].type === "lake") return; + used[e] = 1; + queue.push(e); + }); + } + + const proper = getAdjective(Names.getCultureShort(cells.culture[cell])); + const name = proper + " Tsunami"; + zonesData.push({name, type: "Disaster", cells: cellsArray, fill: "url(#hatch13)"}); + } + + function drawZones() { + zones + .selectAll("g") + .data(zonesData) + .enter() + .append("g") + .attr("id", (d, i) => "zone" + i) + .attr("data-description", d => d.name) + .attr("data-type", d => d.type) + .attr("data-cells", d => d.cells.join(",")) + .attr("fill", d => d.fill) + .selectAll("polygon") + .data(d => d.cells) + .enter() + .append("polygon") + .attr("points", d => getPackPolygon(d)) + .attr("id", function (d) { + return this.parentNode.id + "_" + d; + }); + } + + TIME && console.timeEnd("addZones"); +} diff --git a/src/scripts/generation.js b/src/scripts/generation.js new file mode 100644 index 00000000..a0de0674 --- /dev/null +++ b/src/scripts/generation.js @@ -0,0 +1,284 @@ +import {ERROR, INFO, WARN} from "config/logging"; +import {initLayers, renderLayer, restoreLayers} from "layers"; +import {drawCoastline} from "modules/coastline"; +import {calculateMapCoordinates, defineMapSize} from "modules/coordinates"; +import {markFeatures, markupGridOcean} from "modules/markup"; +import {drawScaleBar, Rulers} from "modules/measurers"; +import {generatePrecipitation} from "modules/precipitation"; +import {calculateTemperatures} from "modules/temperature"; +import {applyMapSize, randomizeOptions} from "modules/ui/options"; +import {applyStyleOnLoad} from "modules/ui/stylePresets"; +import {addZones} from "modules/zones"; +import {aleaPRNG} from "scripts/aleaPRNG"; +import {hideLoading, showLoading} from "scripts/loading"; +import {clearMainTip, tip} from "scripts/tooltips"; +import {parseError} from "utils/errorUtils"; +import {debounce} from "utils/functionUtils"; +import {generateGrid, shouldRegenerateGrid} from "utils/graphUtils"; +import {rn} from "utils/numberUtils"; +import {generateSeed} from "utils/probabilityUtils"; +import {byId} from "utils/shorthands"; +import {showStatistics} from "./statistics"; +import {reGraph} from "./reGraph"; +import {rankCells} from "./rankCells"; + +export async function generate(options) { + try { + const timeStart = performance.now(); + const {seed: precreatedSeed, graph: precreatedGraph} = options || {}; + + Zoom.invoke(); + setSeed(precreatedSeed); + INFO && console.group("Generated Map " + seed); + + applyMapSize(); + randomizeOptions(); + + if (shouldRegenerateGrid(grid)) grid = precreatedGraph || generateGrid(); + else delete grid.cells.h; + grid.cells.h = await HeightmapGenerator.generate(grid); + + markFeatures(); + markupGridOcean(); + + Lakes.addLakesInDeepDepressions(); + Lakes.openNearSeaLakes(); + + OceanLayers(); + defineMapSize(); + window.mapCoordinates = calculateMapCoordinates(); + calculateTemperatures(); + generatePrecipitation(); + + reGraph(); + drawCoastline(); + + Rivers.generate(); + renderLayer("rivers"); + Lakes.defineGroup(); + Biomes.define(); + + rankCells(); + Cultures.generate(); + Cultures.expand(); + BurgsAndStates.generate(); + Religions.generate(); + BurgsAndStates.defineStateForms(); + BurgsAndStates.generateProvinces(); + BurgsAndStates.defineBurgFeatures(); + + renderLayer("states"); + renderLayer("borders"); + BurgsAndStates.drawStateLabels(); + + Rivers.specify(); + Lakes.generateName(); + + Military.generate(); + Markers.generate(); + addZones(); + + drawScaleBar(scale); + Names.getMapName(); + + WARN && console.warn(`TOTAL: ${rn((performance.now() - timeStart) / 1000, 2)}s`); + showStatistics(); + INFO && console.groupEnd("Generated Map " + seed); + } catch (error) { + ERROR && console.error(error); + const parsedError = parseError(error); + clearMainTip(); + + alertMessage.innerHTML = /* html */ `An error has occurred on map generation. Please retry.
If error is critical, clear the stored data and try again. +

${parsedError}

`; + $("#alert").dialog({ + resizable: false, + title: "Generation error", + width: "32em", + buttons: { + "Clear data": function () { + localStorage.clear(); + localStorage.setItem("version", version); + }, + Regenerate: function () { + regenerateMap("generation error"); + $(this).dialog("close"); + }, + Ignore: function () { + $(this).dialog("close"); + } + }, + position: {my: "center", at: "center", of: "svg"} + }); + } +} + +export async function generateMapOnLoad() { + await applyStyleOnLoad(); // apply previously selected default or custom style + await generate(); // generate map + focusOn(); // based on searchParams focus on point, cell or burg from MFCG + initLayers(); // apply saved layers data +} + +// clear the map +export function undraw() { + viewbox.selectAll("path, circle, polygon, line, text, use, #zones > g, #armies > g, #ruler > g").remove(); + document + .getElementById("deftemp") + .querySelectorAll("path, clipPath, svg") + .forEach(el => el.remove()); + byId("coas").innerHTML = ""; // remove auto-generated emblems + notes = []; + rulers = new Rulers(); + unfog(); +} + +export const regenerateMap = debounce(async function (options) { + WARN && console.warn("Generate new random map"); + + const cellsDesired = +byId("pointsInput").dataset.cells; + const shouldShowLoading = cellsDesired > 10000; + shouldShowLoading && showLoading(); + + closeDialogs("#worldConfigurator, #options3d"); + customization = 0; + Zoom.reset(1000); + undraw(); + await generate(options); + restoreLayers(); + if (ThreeD.options.isOn) ThreeD.redraw(); + if ($("#worldConfigurator").is(":visible")) editWorld(); + + shouldShowLoading && hideLoading(); + clearMainTip(); +}, 250); + +// focus on coordinates, cell or burg provided in searchParams +function focusOn() { + const url = new URL(window.location.href); + const params = url.searchParams; + + const fromMGCG = params.get("from") === "MFCG" && document.referrer; + if (fromMGCG) { + if (params.get("seed").length === 13) { + // show back burg from MFCG + const burgSeed = params.get("seed").slice(-4); + params.set("burg", burgSeed); + } else { + // select burg for MFCG + findBurgForMFCG(params); + return; + } + } + + const scaleParam = params.get("scale"); + const cellParam = params.get("cell"); + const burgParam = params.get("burg"); + + if (scaleParam || cellParam || burgParam) { + const scale = +scaleParam || 8; + + if (cellParam) { + const cell = +params.get("cell"); + const [x, y] = pack.cells.p[cell]; + Zoom.to(x, y, scale, 1600); + return; + } + + if (burgParam) { + const burg = isNaN(+burgParam) ? pack.burgs.find(burg => burg.name === burgParam) : pack.burgs[+burgParam]; + if (!burg) return; + + const {x, y} = burg; + Zoom.to(x, y, scale, 1600); + return; + } + + const x = +params.get("x") || graphWidth / 2; + const y = +params.get("y") || graphHeight / 2; + Zoom.to(x, y, scale, 1600); + } +} + +// find burg for MFCG and focus on it +function findBurgForMFCG(params) { + const {cells, burgs} = pack; + + if (pack.burgs.length < 2) { + ERROR && console.error("Cannot select a burg for MFCG"); + return; + } + + // used for selection + const size = +params.get("size"); + const coast = +params.get("coast"); + const port = +params.get("port"); + const river = +params.get("river"); + + let selection = defineSelection(coast, port, river); + if (!selection.length) selection = defineSelection(coast, !port, !river); + if (!selection.length) selection = defineSelection(!coast, 0, !river); + if (!selection.length) selection = [burgs[1]]; // select first if nothing is found + + function defineSelection(coast, port, river) { + if (port && river) return burgs.filter(b => b.port && cells.r[b.cell]); + if (!port && coast && river) return burgs.filter(b => !b.port && cells.t[b.cell] === 1 && cells.r[b.cell]); + if (!coast && !river) return burgs.filter(b => cells.t[b.cell] !== 1 && !cells.r[b.cell]); + if (!coast && river) return burgs.filter(b => cells.t[b.cell] !== 1 && cells.r[b.cell]); + if (coast && river) return burgs.filter(b => cells.t[b.cell] === 1 && cells.r[b.cell]); + return []; + } + + // select a burg with closest population from selection + const selected = d3.scan(selection, (a, b) => Math.abs(a.population - size) - Math.abs(b.population - size)); + const burgId = selection[selected].i; + if (!burgId) { + ERROR && console.error("Cannot select a burg for MFCG"); + return; + } + + const b = burgs[burgId]; + const referrer = new URL(document.referrer); + for (let p of referrer.searchParams) { + if (p[0] === "name") b.name = p[1]; + else if (p[0] === "size") b.population = +p[1]; + else if (p[0] === "seed") b.MFCG = +p[1]; + else if (p[0] === "shantytown") b.shanty = +p[1]; + else b[p[0]] = +p[1]; // other parameters + } + if (params.get("name") && params.get("name") != "null") b.name = params.get("name"); + + const label = burgLabels.select("[data-id='" + burgId + "']"); + if (label.size()) { + label + .text(b.name) + .classed("drag", true) + .on("mouseover", function () { + d3.select(this).classed("drag", false); + label.on("mouseover", null); + }); + } + + Zoom.to(b.x, b.y, 8, 1600); + Zoom.invoke(); + tip("Here stands the glorious city of " + b.name, true, "success", 15000); +} + +// set map seed (string!) +function setSeed(precreatedSeed) { + if (!precreatedSeed) { + const first = !mapHistory[0]; + const url = new URL(window.location.href); + const params = url.searchParams; + const urlSeed = url.searchParams.get("seed"); + if (first && params.get("from") === "MFCG" && urlSeed.length === 13) seed = urlSeed.slice(0, -4); + else if (first && urlSeed) seed = urlSeed; + else if (optionsSeed.value && optionsSeed.value != seed) seed = optionsSeed.value; + else seed = generateSeed(); + } else { + seed = precreatedSeed; + } + + byId("optionsSeed").value = seed; + Math.random = aleaPRNG(seed); +} diff --git a/src/scripts/listeners.ts b/src/scripts/listeners.ts index 83ca015e..dc1870e4 100644 --- a/src/scripts/listeners.ts +++ b/src/scripts/listeners.ts @@ -1,11 +1,16 @@ import {PRODUCTION} from "../constants"; +// @ts-ignore +import {checkIfServerless} from "./loading"; import {assignLockBehavior} from "./options/lock"; import {addTooptipListers} from "./tooltips"; import {assignSpeakerBehavior} from "./speaker"; // @ts-ignore import {addResizeListener} from "modules/ui/options"; +// @ts-ignore +import {addDragToUpload} from "modules/io/load"; export function addGlobalListeners() { + checkIfServerless(); if (PRODUCTION) { registerServiceWorker(); addInstallationPrompt(); @@ -15,6 +20,7 @@ export function addGlobalListeners() { addTooptipListers(); addResizeListener(); assignSpeakerBehavior(); + addDragToUpload(); } function registerServiceWorker() { diff --git a/src/scripts/loading.js b/src/scripts/loading.js new file mode 100644 index 00000000..cff11eb2 --- /dev/null +++ b/src/scripts/loading.js @@ -0,0 +1,100 @@ +import {ERROR, WARN} from "config/logging"; +import {generateMapOnLoad} from "./generation"; +import {loadMapFromURL} from "modules/io/load"; +import {restoreDefaultEvents} from "scripts/events"; + +export function checkIfServerless() { + document.on("DOMContentLoaded", async () => { + if (!location.hostname) { + const wiki = "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Run-FMG-locally"; + alertMessage.innerHTML = `Fantasy Map Generator cannot run serverless. Follow the instructions on how you can + easily run a local web-server`; + + $("#alert").dialog({ + resizable: false, + title: "Loading error", + width: "28em", + position: {my: "center center-4em", at: "center", of: "svg"}, + buttons: { + OK: function () { + $(this).dialog("close"); + } + } + }); + } else { + hideLoading(); + await checkLoadParameters(); + } + restoreDefaultEvents(); // apply default viewbox events + }); +} + +// decide which map should be loaded or generated on page load +async function checkLoadParameters() { + const url = new URL(window.location.href); + const params = url.searchParams; + + // of there is a valid maplink, try to load .map file from URL + if (params.get("maplink")) { + WARN && console.warn("Load map from URL"); + const maplink = params.get("maplink"); + const pattern = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/; + const valid = pattern.test(maplink); + if (valid) { + setTimeout(() => { + loadMapFromURL(maplink, 1); + }, 1000); + return; + } else showUploadErrorMessage("Map link is not a valid URL", maplink); + } + + // if there is a seed (user of MFCG provided), generate map for it + if (params.get("seed")) { + WARN && console.warn("Generate map for seed"); + await generateMapOnLoad(); + return; + } + + // open latest map if option is active and map is stored + const loadLastMap = () => + new Promise((resolve, reject) => { + ldb.get("lastMap", blob => { + if (blob) { + WARN && console.warn("Load last saved map"); + try { + uploadMap(blob); + resolve(); + } catch (error) { + reject(error); + } + } else { + reject("No map stored"); + } + }); + }); + + if (onloadMap.value === "saved") { + try { + await loadLastMap(); + } catch (error) { + ERROR && console.error(error); + WARN && console.warn("Cannot load stored map, random map to be generated"); + await generateMapOnLoad(); + } + } else { + WARN && console.warn("Generate random map"); + await generateMapOnLoad(); + } +} + +export function hideLoading() { + d3.select("#loading").transition().duration(3000).style("opacity", 0); + d3.select("#optionsContainer").transition().duration(2000).style("opacity", 1); + d3.select("#tooltip").transition().duration(3000).style("opacity", 1); +} + +export function showLoading() { + d3.select("#loading").transition().duration(200).style("opacity", 1); + d3.select("#optionsContainer").transition().duration(100).style("opacity", 0); + d3.select("#tooltip").transition().duration(200).style("opacity", 0); +} diff --git a/src/scripts/rankCells.js b/src/scripts/rankCells.js new file mode 100644 index 00000000..0d738949 --- /dev/null +++ b/src/scripts/rankCells.js @@ -0,0 +1,44 @@ +import {TIME} from "config/logging"; +import {normalize} from "utils/numberUtils"; + +// assess cells suitability to calculate population and rand cells for culture center and burgs placement +export function rankCells() { + TIME && console.time("rankCells"); + const {cells, features} = pack; + cells.s = new Int16Array(cells.i.length); // cell suitability array + cells.pop = new Float32Array(cells.i.length); // cell population array + + const flMean = d3.median(cells.fl.filter(f => f)) || 0; + const flMax = d3.max(cells.fl) + d3.max(cells.conf); // to normalize flux + const areaMean = d3.mean(cells.area); // to adjust population by cell area + + for (const i of cells.i) { + if (cells.h[i] < 20) continue; // no population in water + let s = +biomesData.habitability[cells.biome[i]]; // base suitability derived from biome habitability + if (!s) continue; // uninhabitable biomes has 0 suitability + if (flMean) s += normalize(cells.fl[i] + cells.conf[i], flMean, flMax) * 250; // big rivers and confluences are valued + s -= (cells.h[i] - 50) / 5; // low elevation is valued, high is not; + + if (cells.t[i] === 1) { + if (cells.r[i]) s += 15; // estuary is valued + const feature = features[cells.f[cells.haven[i]]]; + if (feature.type === "lake") { + if (feature.group === "freshwater") s += 30; + else if (feature.group == "salt") s += 10; + else if (feature.group == "frozen") s += 1; + else if (feature.group == "dry") s -= 5; + else if (feature.group == "sinkhole") s -= 5; + else if (feature.group == "lava") s -= 30; + } else { + s += 5; // ocean coast is valued + if (cells.harbor[i] === 1) s += 20; // safe sea harbor is valued + } + } + + cells.s[i] = s / 5; // general population rate + // cell rural population is suitability adjusted by cell area + cells.pop[i] = cells.s[i] > 0 ? (cells.s[i] * cells.area[i]) / areaMean : 0; + } + + TIME && console.timeEnd("rankCells"); +} diff --git a/src/scripts/reGraph.js b/src/scripts/reGraph.js new file mode 100644 index 00000000..6fdc24ca --- /dev/null +++ b/src/scripts/reGraph.js @@ -0,0 +1,60 @@ +import {TIME} from "config/logging"; +import {UINT16_MAX} from "constants"; +import {createTypedArray} from "utils/arrayUtils"; +import {calculateVoronoi, getPackPolygon} from "utils/graphUtils"; +import {rn} from "utils/numberUtils"; + +// recalculate Voronoi Graph to pack cells +export function reGraph() { + TIME && console.time("reGraph"); + const {cells: gridCells, points, features} = grid; + const newCells = {p: [], g: [], h: []}; // store new data + const spacing2 = grid.spacing ** 2; + + 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]; + + 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 + gridCells.c[i].forEach(e => { + if (i > e) return; + if (gridCells.t[e] === type) { + const dist2 = (y - points[e][1]) ** 2 + (x - points[e][0]) ** 2; + if (dist2 < spacing2) return; // too close to each other + const x1 = rn((x + points[e][0]) / 2, 1); + const y1 = rn((y + points[e][1]) / 2, 1); + addNewPoint(i, x1, y1, height); + } + }); + } + } + + function addNewPoint(i, x, y, height) { + newCells.p.push([x, y]); + newCells.g.push(i); + newCells.h.push(height); + } + + function getCellArea(i) { + const area = Math.abs(d3.polygonArea(getPackPolygon(i))); + return Math.min(area, UINT16_MAX); + } + + const {cells: packCells, vertices} = calculateVoronoi(newCells.p, grid.boundary); + pack.vertices = vertices; + pack.cells = packCells; + pack.cells.p = newCells.p; + pack.cells.g = createTypedArray({maxValue: grid.points.length, from: newCells.g}); + pack.cells.q = d3.quadtree(newCells.p.map(([x, y], i) => [x, y, i])); + pack.cells.h = createTypedArray({maxValue: 100, from: newCells.h}); + pack.cells.area = createTypedArray({maxValue: UINT16_MAX, from: pack.cells.i}).map(getCellArea); + + TIME && console.timeEnd("reGraph"); +} diff --git a/src/scripts/statistics b/src/scripts/statistics new file mode 100644 index 00000000..d437f715 --- /dev/null +++ b/src/scripts/statistics @@ -0,0 +1,29 @@ +import {INFO} from "config/logging"; +import {byId} from "utils/shorthands"; +import {heightmapTemplates} from "config/heightmap-templates"; +import {locked} from "scripts/options/lock"; + +// show map stats on generation complete +export function showStatistics() { + const heightmap = byId("templateInput").value; + const isTemplate = heightmap in heightmapTemplates; + const heightmapType = isTemplate ? "template" : "precreated"; + const isRandomTemplate = isTemplate && !locked("template") ? "random " : ""; + + const stats = ` Seed: ${seed} + Canvas size: ${graphWidth}x${graphHeight} px + Heightmap: ${heightmap} (${isRandomTemplate}${heightmapType}) + Points: ${grid.points.length} + Cells: ${pack.cells.i.length} + Map size: ${mapSizeOutput.value}% + States: ${pack.states.length - 1} + Provinces: ${pack.provinces.length - 1} + Burgs: ${pack.burgs.length - 1} + Religions: ${pack.religions.length - 1} + Culture set: ${culturesSet.selectedOptions[0].innerText} + Cultures: ${pack.cultures.length - 1}`; + + mapId = Date.now(); // unique map id is it's creation date number + mapHistory.push({seed, width: graphWidth, height: graphHeight, template: heightmap, created: mapId}); + INFO && console.log(stats); +}