mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-16 17:31:24 +01:00
* Scale bar styling (#1025) * feat: style scale bar * feat: style scale bar - style presets --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> * Ocean heightmap to v1.96 (#1044) * feat: allow to render ocean heightmap * feat: allow to render ocean heightmap - test * feat: allow to render ocean heightmap - fix issue * feat: allow to render ocean heightmap - cleanup --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> * fix: scale bar size * fix: remove mask on terrs lavel * fix: regenerate heigtmap preview to use current graph size * Add the name of culture and namesbase in the name editor dialog (#1033) * Add the name of culture and namesbase in the name editor dialog Added the name of the culture and namesbase in the dialog "name editor". This tells information on the "click to generate a culture-specific name" It tells you the culture before changing name. * cultureName into cultureId + cultureName And deleted the incomplete code of showing culture name on datatip * refactor: leave culture name only --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> * Added Burgs column to province editor (#1031) * Added Burgs column to province editor Added to province editor: + Burgs column + the number of Burgs, p.burgs.length + "icon-dot-circled" to go to overviewBurgs. + overviewBurgs Filtered by state id. + Fixed some typos. * fixed code as Azgaar suggested + Corrected provincesHeader distance in em. + const stateId = pack.provinces[p].state; - Deleted cell count. * deleted HTML code for provincesFooter cells - Deleted Total land cells number HTML from provincesFooter. * deleting totalCells in the code, maybe i will add provinceCells in the future. Deleted lines for const totalCells and for (+cells / totalCells) * 100 + "%"; * refactor: cleanup * refactor: cleanup --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> * fix: burgs overview - add MFCG link back * feat: add more details to burgs export * feat: don't show auto-update dialog * feat: pump version * fix: #1041 * feat: update style presets --------- Co-authored-by: Azgaar <azgaar.fmg@yandex.com> Co-authored-by: Ángel Montero Lamas <angel.montero1@gmail.com>
1974 lines
66 KiB
JavaScript
1974 lines
66 KiB
JavaScript
"use strict";
|
|
// Azgaar (azgaar.fmg@yandex.com). Minsk, 2017-2023. MIT License
|
|
// https://github.com/Azgaar/Fantasy-Map-Generator
|
|
|
|
// set debug options
|
|
const PRODUCTION = location.hostname && location.hostname !== "localhost" && location.hostname !== "127.0.0.1";
|
|
const DEBUG = localStorage.getItem("debug");
|
|
const INFO = true;
|
|
const TIME = true;
|
|
const WARN = true;
|
|
const ERROR = true;
|
|
|
|
// detect device
|
|
const MOBILE = window.innerWidth < 600 || navigator.userAgentData?.mobile;
|
|
|
|
// typed arrays max values
|
|
const UINT8_MAX = 255;
|
|
const UINT16_MAX = 65535;
|
|
const UINT32_MAX = 4294967295;
|
|
|
|
if (PRODUCTION && "serviceWorker" in navigator) {
|
|
window.addEventListener("load", () => {
|
|
navigator.serviceWorker.register("./sw.js").catch(err => {
|
|
console.error("ServiceWorker registration failed: ", err);
|
|
});
|
|
});
|
|
|
|
window.addEventListener(
|
|
"beforeinstallprompt",
|
|
async event => {
|
|
event.preventDefault();
|
|
const Installation = await import("./modules/dynamic/installation.js?v=1.89.19");
|
|
Installation.init(event);
|
|
},
|
|
{once: true}
|
|
);
|
|
}
|
|
|
|
// append svg layers (in default order)
|
|
let svg = d3.select("#map");
|
|
let defs = svg.select("#deftemp");
|
|
let viewbox = svg.select("#viewbox");
|
|
let scaleBar = svg.select("#scaleBar");
|
|
let legend = svg.append("g").attr("id", "legend");
|
|
let ocean = viewbox.append("g").attr("id", "ocean");
|
|
let oceanLayers = ocean.append("g").attr("id", "oceanLayers");
|
|
let oceanPattern = ocean.append("g").attr("id", "oceanPattern");
|
|
let lakes = viewbox.append("g").attr("id", "lakes");
|
|
let landmass = viewbox.append("g").attr("id", "landmass");
|
|
let texture = viewbox.append("g").attr("id", "texture");
|
|
let terrs = viewbox.append("g").attr("id", "terrs");
|
|
let biomes = viewbox.append("g").attr("id", "biomes");
|
|
let cells = viewbox.append("g").attr("id", "cells");
|
|
let gridOverlay = viewbox.append("g").attr("id", "gridOverlay");
|
|
let coordinates = viewbox.append("g").attr("id", "coordinates");
|
|
let compass = viewbox.append("g").attr("id", "compass");
|
|
let rivers = viewbox.append("g").attr("id", "rivers");
|
|
let terrain = viewbox.append("g").attr("id", "terrain");
|
|
let relig = viewbox.append("g").attr("id", "relig");
|
|
let cults = viewbox.append("g").attr("id", "cults");
|
|
let regions = viewbox.append("g").attr("id", "regions");
|
|
let statesBody = regions.append("g").attr("id", "statesBody");
|
|
let statesHalo = regions.append("g").attr("id", "statesHalo");
|
|
let provs = viewbox.append("g").attr("id", "provs");
|
|
let zones = viewbox.append("g").attr("id", "zones").style("display", "none");
|
|
let borders = viewbox.append("g").attr("id", "borders");
|
|
let stateBorders = borders.append("g").attr("id", "stateBorders");
|
|
let provinceBorders = borders.append("g").attr("id", "provinceBorders");
|
|
let routes = viewbox.append("g").attr("id", "routes");
|
|
let roads = routes.append("g").attr("id", "roads");
|
|
let trails = routes.append("g").attr("id", "trails");
|
|
let searoutes = routes.append("g").attr("id", "searoutes");
|
|
let temperature = viewbox.append("g").attr("id", "temperature");
|
|
let coastline = viewbox.append("g").attr("id", "coastline");
|
|
let ice = viewbox.append("g").attr("id", "ice").style("display", "none");
|
|
let prec = viewbox.append("g").attr("id", "prec").style("display", "none");
|
|
let population = viewbox.append("g").attr("id", "population");
|
|
let emblems = viewbox.append("g").attr("id", "emblems").style("display", "none");
|
|
let labels = viewbox.append("g").attr("id", "labels");
|
|
let icons = viewbox.append("g").attr("id", "icons");
|
|
let burgIcons = icons.append("g").attr("id", "burgIcons");
|
|
let anchors = icons.append("g").attr("id", "anchors");
|
|
let armies = viewbox.append("g").attr("id", "armies").style("display", "none");
|
|
let markers = viewbox.append("g").attr("id", "markers");
|
|
let fogging = viewbox
|
|
.append("g")
|
|
.attr("id", "fogging-cont")
|
|
.attr("mask", "url(#fog)")
|
|
.append("g")
|
|
.attr("id", "fogging")
|
|
.style("display", "none");
|
|
let ruler = viewbox.append("g").attr("id", "ruler").style("display", "none");
|
|
let debug = viewbox.append("g").attr("id", "debug");
|
|
|
|
lakes.append("g").attr("id", "freshwater");
|
|
lakes.append("g").attr("id", "salt");
|
|
lakes.append("g").attr("id", "sinkhole");
|
|
lakes.append("g").attr("id", "frozen");
|
|
lakes.append("g").attr("id", "lava");
|
|
lakes.append("g").attr("id", "dry");
|
|
|
|
coastline.append("g").attr("id", "sea_island");
|
|
coastline.append("g").attr("id", "lake_island");
|
|
|
|
terrs.append("g").attr("id", "oceanHeights");
|
|
terrs.append("g").attr("id", "landHeights");
|
|
|
|
labels.append("g").attr("id", "states");
|
|
labels.append("g").attr("id", "addedLabels");
|
|
|
|
let burgLabels = labels.append("g").attr("id", "burgLabels");
|
|
burgIcons.append("g").attr("id", "cities");
|
|
burgLabels.append("g").attr("id", "cities");
|
|
anchors.append("g").attr("id", "cities");
|
|
|
|
burgIcons.append("g").attr("id", "towns");
|
|
burgLabels.append("g").attr("id", "towns");
|
|
anchors.append("g").attr("id", "towns");
|
|
|
|
// population groups
|
|
population.append("g").attr("id", "rural");
|
|
population.append("g").attr("id", "urban");
|
|
|
|
// emblem groups
|
|
emblems.append("g").attr("id", "burgEmblems");
|
|
emblems.append("g").attr("id", "provinceEmblems");
|
|
emblems.append("g").attr("id", "stateEmblems");
|
|
|
|
// fogging
|
|
fogging.append("rect").attr("x", 0).attr("y", 0).attr("width", "100%").attr("height", "100%");
|
|
fogging
|
|
.append("rect")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("width", "100%")
|
|
.attr("height", "100%")
|
|
.attr("fill", "#e8f0f6")
|
|
.attr("filter", "url(#splotch)");
|
|
|
|
// assign events separately as not a viewbox child
|
|
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());
|
|
|
|
// main data variables
|
|
let grid = {}; // initial graph based on jittered square grid and data
|
|
let pack = {}; // packed graph and data
|
|
let seed;
|
|
let mapId;
|
|
let mapHistory = [];
|
|
let elSelected;
|
|
let modules = {};
|
|
let notes = [];
|
|
let rulers = new Rulers();
|
|
let customization = 0;
|
|
|
|
let biomesData = Biomes.getDefault();
|
|
let nameBases = Names.getNameBases(); // cultures-related data
|
|
|
|
let color = d3.scaleSequential(d3.interpolateSpectral); // default color scheme
|
|
const lineGen = d3.line().curve(d3.curveBasis); // d3 line generator with default curve interpolation
|
|
|
|
// d3 zoom behavior
|
|
let scale = 1;
|
|
let viewX = 0;
|
|
let viewY = 0;
|
|
|
|
function onZoom() {
|
|
const {k, x, y} = d3.event.transform;
|
|
|
|
const isScaleChanged = Boolean(scale - k);
|
|
const isPositionChanged = Boolean(viewX - x || viewY - y);
|
|
if (!isScaleChanged && !isPositionChanged) return;
|
|
|
|
scale = k;
|
|
viewX = x;
|
|
viewY = y;
|
|
|
|
handleZoom(isScaleChanged, isPositionChanged);
|
|
}
|
|
const onZoomDebouced = debounce(onZoom, 50);
|
|
const zoom = d3.zoom().scaleExtent([1, 20]).on("zoom", onZoomDebouced);
|
|
|
|
// default options, based on Earth data
|
|
let options = {
|
|
pinNotes: false,
|
|
showMFCGMap: true,
|
|
winds: [225, 45, 225, 315, 135, 315],
|
|
temperatureEquator: 27,
|
|
temperatureNorthPole: -30,
|
|
temperatureSouthPole: -15,
|
|
stateLabelsMode: "auto"
|
|
};
|
|
|
|
let mapCoordinates = {}; // map coordinates on globe
|
|
let populationRate = +document.getElementById("populationRateInput").value;
|
|
let distanceScale = +document.getElementById("distanceScaleInput").value;
|
|
let urbanization = +document.getElementById("urbanizationInput").value;
|
|
let urbanDensity = +document.getElementById("urbanDensityInput").value;
|
|
|
|
applyStoredOptions();
|
|
|
|
// voronoi graph extension, cannot be changed after generation
|
|
let graphWidth = +mapWidthInput.value;
|
|
let graphHeight = +mapHeightInput.value;
|
|
|
|
// svg canvas resolution, can be changed
|
|
let svgWidth = graphWidth;
|
|
let svgHeight = graphHeight;
|
|
|
|
landmass.append("rect").attr("x", 0).attr("y", 0).attr("width", graphWidth).attr("height", graphHeight);
|
|
oceanPattern
|
|
.append("rect")
|
|
.attr("fill", "url(#oceanic)")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("width", graphWidth)
|
|
.attr("height", graphHeight);
|
|
oceanLayers
|
|
.append("rect")
|
|
.attr("id", "oceanBase")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("width", graphWidth)
|
|
.attr("height", graphHeight);
|
|
|
|
document.addEventListener("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 <a href="${wiki}" target="_blank">instructions</a> 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
|
|
initiateAutosave();
|
|
});
|
|
|
|
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/.gz 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;
|
|
}
|
|
|
|
// check if there is a map saved to indexedDB
|
|
if (byId("onloadBehavior").value === "lastSaved") {
|
|
try {
|
|
const blob = await ldb.get("lastMap");
|
|
if (blob) {
|
|
WARN && console.warn("Loading last stored map");
|
|
uploadMap(blob);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
ERROR && console.error(error);
|
|
}
|
|
}
|
|
|
|
// else generate random map
|
|
WARN && console.warn("Generate random map");
|
|
generateMapOnLoad();
|
|
}
|
|
|
|
async function generateMapOnLoad() {
|
|
await applyStyleOnLoad(); // apply previously selected default or custom style
|
|
await generate(); // generate map
|
|
applyPreset(); // apply saved layers preset
|
|
fitMapToScreen();
|
|
focusOn(); // based on searchParams focus on point, cell or burg from MFCG
|
|
}
|
|
|
|
// 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];
|
|
zoomTo(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;
|
|
zoomTo(x, y, scale, 1600);
|
|
return;
|
|
}
|
|
|
|
const x = +params.get("x") || graphWidth / 2;
|
|
const y = +params.get("y") || graphHeight / 2;
|
|
zoomTo(x, y, scale, 1600);
|
|
}
|
|
}
|
|
|
|
// find burg for MFCG and focus on it
|
|
function findBurgForMFCG(params) {
|
|
const cells = pack.cells,
|
|
burgs = pack.burgs;
|
|
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);
|
|
});
|
|
}
|
|
|
|
zoomTo(b.x, b.y, 8, 1600);
|
|
invokeActiveZooming();
|
|
tip("Here stands the glorious city of " + b.name, true, "success", 15000);
|
|
}
|
|
|
|
function handleZoom(isScaleChanged, isPositionChanged) {
|
|
viewbox.attr("transform", `translate(${viewX} ${viewY}) scale(${scale})`);
|
|
|
|
if (isPositionChanged) drawCoordinates();
|
|
|
|
if (isScaleChanged) {
|
|
invokeActiveZooming();
|
|
drawScaleBar(scaleBar, scale);
|
|
fitScaleBar(scaleBar, svgWidth, svgHeight);
|
|
}
|
|
|
|
// zoom image converter overlay
|
|
if (customization === 1) {
|
|
const canvas = document.getElementById("canvas");
|
|
if (!canvas || canvas.style.opacity === "0") return;
|
|
|
|
const img = document.getElementById("imageToConvert");
|
|
if (!img) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.setTransform(scale, 0, 0, scale, viewX, viewY);
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
}
|
|
}
|
|
|
|
// Zoom to a specific point
|
|
function zoomTo(x, y, z = 8, d = 2000) {
|
|
const transform = d3.zoomIdentity.translate(x * -z + graphWidth / 2, y * -z + graphHeight / 2).scale(z);
|
|
svg.transition().duration(d).call(zoom.transform, transform);
|
|
}
|
|
|
|
// Reset zoom to initial
|
|
function resetZoom(d = 1000) {
|
|
svg.transition().duration(d).call(zoom.transform, d3.zoomIdentity);
|
|
}
|
|
|
|
// calculate x y extreme points of viewBox
|
|
function getViewBoxExtent() {
|
|
return [
|
|
[Math.abs(viewX / scale), Math.abs(viewY / scale)],
|
|
[Math.abs(viewX / scale) + graphWidth / scale, Math.abs(viewY / scale) + graphHeight / scale]
|
|
];
|
|
}
|
|
|
|
// active zooming feature
|
|
function invokeActiveZooming() {
|
|
const isOptimized = shapeRendering.value === "optimizeSpeed";
|
|
|
|
if (coastline.select("#sea_island").size() && +coastline.select("#sea_island").attr("auto-filter")) {
|
|
// toggle shade/blur filter for coatline on zoom
|
|
const filter = scale > 1.5 && scale <= 2.6 ? null : scale > 2.6 ? "url(#blurFilter)" : "url(#dropShadow)";
|
|
coastline.select("#sea_island").attr("filter", filter);
|
|
}
|
|
|
|
// rescale labels on zoom
|
|
if (labels.style("display") !== "none") {
|
|
labels.selectAll("g").each(function () {
|
|
if (this.id === "burgLabels") return;
|
|
const desired = +this.dataset.size;
|
|
const relative = Math.max(rn((desired + desired / scale) / 2, 2), 1);
|
|
if (rescaleLabels.checked) this.setAttribute("font-size", relative);
|
|
|
|
const hidden = hideLabels.checked && (relative * scale < 6 || relative * scale > 60);
|
|
if (hidden) this.classList.add("hidden");
|
|
else this.classList.remove("hidden");
|
|
});
|
|
}
|
|
|
|
// rescale emblems on zoom
|
|
if (emblems.style("display") !== "none") {
|
|
emblems.selectAll("g").each(function () {
|
|
const size = this.getAttribute("font-size") * scale;
|
|
const hidden = hideEmblems.checked && (size < 25 || size > 300);
|
|
if (hidden) this.classList.add("hidden");
|
|
else this.classList.remove("hidden");
|
|
if (!hidden && window.COArenderer && this.children.length && !this.children[0].getAttribute("href"))
|
|
renderGroupCOAs(this);
|
|
});
|
|
}
|
|
|
|
// turn off ocean pattern if scale is big (improves performance)
|
|
oceanPattern
|
|
.select("rect")
|
|
.attr("fill", scale > 10 ? "#fff" : "url(#oceanic)")
|
|
.attr("opacity", scale > 10 ? 0.2 : null);
|
|
|
|
// change states halo width
|
|
if (!customization && !isOptimized) {
|
|
const desired = +statesHalo.attr("data-width");
|
|
const haloSize = rn(desired / scale ** 0.8, 2);
|
|
statesHalo.attr("stroke-width", haloSize).style("display", haloSize > 0.1 ? "block" : "none");
|
|
}
|
|
|
|
// rescale map markers
|
|
+markers.attr("rescale") &&
|
|
pack.markers?.forEach(marker => {
|
|
const {i, x, y, size = 30, hidden} = marker;
|
|
const el = !hidden && document.getElementById(`marker${i}`);
|
|
if (!el) return;
|
|
|
|
const zoomedSize = Math.max(rn(size / 5 + 24 / scale, 2), 1);
|
|
el.setAttribute("width", zoomedSize);
|
|
el.setAttribute("height", zoomedSize);
|
|
el.setAttribute("x", rn(x - zoomedSize / 2, 1));
|
|
el.setAttribute("y", rn(y - zoomedSize, 1));
|
|
});
|
|
|
|
// rescale rulers to have always the same size
|
|
if (ruler.style("display") !== "none") {
|
|
const size = rn((10 / scale ** 0.3) * 2, 2);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// add drag to upload logic, pull request from @evyatron
|
|
void (function addDragToUpload() {
|
|
document.addEventListener("dragover", function (e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
document.getElementById("mapOverlay").style.display = null;
|
|
});
|
|
|
|
document.addEventListener("dragleave", function (e) {
|
|
document.getElementById("mapOverlay").style.display = "none";
|
|
});
|
|
|
|
document.addEventListener("drop", function (e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
const overlay = document.getElementById("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.endsWith(".map") && !file.name.endsWith(".gz")) {
|
|
alertMessage.innerHTML =
|
|
"Please upload a map file (<i>.map</i> or <i>.gz</i> formats) 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<span>.</span><span>.</span><span>.</span>";
|
|
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 || {};
|
|
|
|
invokeActiveZooming();
|
|
setSeed(precreatedSeed);
|
|
INFO && console.group("Generated Map " + seed);
|
|
|
|
applyGraphSize();
|
|
randomizeOptions();
|
|
|
|
if (shouldRegenerateGrid(grid, precreatedSeed)) grid = precreatedGraph || generateGrid();
|
|
else delete grid.cells.h;
|
|
grid.cells.h = await HeightmapGenerator.generate(grid);
|
|
pack = {}; // reset pack
|
|
|
|
markFeatures();
|
|
markupGridOcean();
|
|
addLakesInDeepDepressions();
|
|
openNearSeaLakes();
|
|
|
|
OceanLayers();
|
|
defineMapSize();
|
|
calculateMapCoordinates();
|
|
calculateTemperatures();
|
|
generatePrecipitation();
|
|
|
|
reGraph();
|
|
drawCoastline();
|
|
|
|
Rivers.generate();
|
|
drawRivers();
|
|
Lakes.defineGroup();
|
|
Biomes.define();
|
|
|
|
rankCells();
|
|
Cultures.generate();
|
|
Cultures.expand();
|
|
BurgsAndStates.generate();
|
|
Religions.generate();
|
|
BurgsAndStates.defineStateForms();
|
|
BurgsAndStates.generateProvinces();
|
|
BurgsAndStates.defineBurgFeatures();
|
|
|
|
drawStates();
|
|
drawBorders();
|
|
drawStateLabels();
|
|
|
|
Rivers.specify();
|
|
Lakes.generateName();
|
|
|
|
Military.generate();
|
|
Markers.generate();
|
|
addZones();
|
|
|
|
drawScaleBar(scaleBar, 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. <br />If error is critical, clear the stored data and try again.
|
|
<p id="errorBox">${parsedError}</p>`;
|
|
$("#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 params = new URL(window.location.href).searchParams;
|
|
const urlSeed = params.get("seed");
|
|
if (first && params.get("from") === "MFCG" && urlSeed.length === 13) seed = urlSeed.slice(0, -4);
|
|
else if (first && urlSeed) seed = urlSeed;
|
|
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 = +document.getElementById("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 break 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 lakeFeatureId = cells.f[i];
|
|
if (features[lakeFeatureId].type !== "lake") continue; // not a lake
|
|
|
|
check_neighbours: for (const c of cells.c[i]) {
|
|
if (cells.t[c] !== 1 || cells.h[c] > LIMIT) continue; // water cannot break this
|
|
|
|
for (const n of cells.c[c]) {
|
|
const ocean = cells.f[n];
|
|
if (features[ocean].type !== "ocean") continue; // not an ocean
|
|
removeLake(c, lakeFeatureId, ocean);
|
|
break check_neighbours;
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeLake(thresholdCellId, lakeFeatureId, oceanFeatureId) {
|
|
cells.h[thresholdCellId] = 19;
|
|
cells.t[thresholdCellId] = -1;
|
|
cells.f[thresholdCellId] = oceanFeatureId;
|
|
cells.c[thresholdCellId].forEach(function (c) {
|
|
if (cells.h[c] >= 20) cells.t[c] = 1; // mark as coastline
|
|
});
|
|
|
|
cells.i.forEach(i => {
|
|
if (cells.f[i] === lakeFeatureId) cells.f[i] = oceanFeatureId;
|
|
});
|
|
features[lakeFeatureId].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, 20, 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 = +document.getElementById("mapSizeOutput").value;
|
|
const latShift = +document.getElementById("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));
|
|
mapCoordinates = {latT, latN, latS, lonT: lon * 2, lonW: -lon, lonE: lon};
|
|
}
|
|
|
|
// temperature model, trying to follow real-world data
|
|
// based on http://www-das.uwyo.edu/~geerts/cwx/notes/chap16/Image64.gif
|
|
function calculateTemperatures() {
|
|
TIME && console.time("calculateTemperatures");
|
|
const cells = grid.cells;
|
|
cells.temp = new Int8Array(cells.i.length); // temperature array
|
|
|
|
const {temperatureEquator, temperatureNorthPole, temperatureSouthPole} = options;
|
|
const tropics = [16, -20]; // tropics zone
|
|
const tropicalGradient = 0.15;
|
|
|
|
const tempNorthTropic = temperatureEquator - tropics[0] * tropicalGradient;
|
|
const northernGradient = (tempNorthTropic - temperatureNorthPole) / (90 - tropics[0]);
|
|
|
|
const tempSouthTropic = temperatureEquator + tropics[1] * tropicalGradient;
|
|
const southernGradient = (tempSouthTropic - temperatureSouthPole) / (90 + tropics[1]);
|
|
|
|
const exponent = +heightExponentInput.value;
|
|
|
|
for (let rowCellId = 0; rowCellId < cells.i.length; rowCellId += grid.cellsX) {
|
|
const [, y] = grid.points[rowCellId];
|
|
const rowLatitude = mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT; // [90; -90]
|
|
const tempSeaLevel = calculateSeaLevelTemp(rowLatitude);
|
|
DEBUG && console.info(`${rn(rowLatitude)}° sea temperature: ${rn(tempSeaLevel)}°C`);
|
|
|
|
for (let cellId = rowCellId; cellId < rowCellId + grid.cellsX; cellId++) {
|
|
const tempAltitudeDrop = getAltitudeTemperatureDrop(cells.h[cellId]);
|
|
cells.temp[cellId] = minmax(tempSeaLevel - tempAltitudeDrop, -128, 127);
|
|
}
|
|
}
|
|
|
|
function calculateSeaLevelTemp(latitude) {
|
|
const isTropical = latitude <= 16 && latitude >= -20;
|
|
if (isTropical) return temperatureEquator - Math.abs(latitude) * tropicalGradient;
|
|
|
|
return latitude > 0
|
|
? tempNorthTropic - (latitude - tropics[0]) * northernGradient
|
|
: tempSouthTropic + (latitude - tropics[1]) * southernGradient;
|
|
}
|
|
|
|
// temperature drops by 6.5°C per 1km of altitude
|
|
function getAltitudeTemperatureDrop(h) {
|
|
if (h < 20) return 0;
|
|
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 = pack.cells,
|
|
vertices = pack.vertices,
|
|
n = cells.i.length,
|
|
features = pack.features;
|
|
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");
|
|
lineGen.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;
|
|
const 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);
|
|
|
|
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 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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 = [],
|
|
cost = [],
|
|
power = rand(20, 37);
|
|
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
|
|
queue.queue({e: burg.cell, p: 0});
|
|
|
|
while (queue.length) {
|
|
const next = queue.dequeue();
|
|
if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e);
|
|
used[next.e] = 1;
|
|
|
|
cells.c[next.e].forEach(function (e) {
|
|
const r = cells.road[next.e];
|
|
const c = r ? Math.max(10 - r, 1) : 100;
|
|
const p = next.p + c;
|
|
if (p > power) return;
|
|
|
|
if (!cost[e] || p < cost[e]) {
|
|
cost[e] = p;
|
|
queue.queue({e, p});
|
|
}
|
|
});
|
|
}
|
|
|
|
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 = [],
|
|
cost = [],
|
|
power = rand(5, 25);
|
|
const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p});
|
|
queue.queue({e: burg.cell, p: 0});
|
|
|
|
while (queue.length) {
|
|
const next = queue.dequeue();
|
|
if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e);
|
|
used[next.e] = 1;
|
|
|
|
cells.c[next.e].forEach(function (e) {
|
|
const c = rand(1, 10);
|
|
const p = next.p + c;
|
|
if (p > power) return;
|
|
|
|
if (!cost[e] || p < cost[e]) {
|
|
cost[e] = p;
|
|
queue.queue({e, p});
|
|
}
|
|
});
|
|
}
|
|
|
|
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 = document.getElementById("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 = [],
|
|
queue = [cell],
|
|
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 = [],
|
|
queue = [cell],
|
|
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 = [],
|
|
queue = [cell],
|
|
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 = [],
|
|
queue = [cell],
|
|
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 = [],
|
|
queue = [cell],
|
|
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.info(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;
|
|
resetZoom(1000);
|
|
undraw();
|
|
await generate(options);
|
|
restoreLayers();
|
|
if (ThreeD.options.isOn) ThreeD.redraw();
|
|
if ($("#worldConfigurator").is(":visible")) editWorld();
|
|
|
|
fitMapToScreen();
|
|
shouldShowLoading && hideLoading();
|
|
clearMainTip();
|
|
}, 250);
|
|
|
|
// clear the map
|
|
function undraw() {
|
|
viewbox
|
|
.selectAll("path, circle, polygon, line, text, use, #texture > image, #zones > g, #armies > g, #ruler > g")
|
|
.remove();
|
|
document
|
|
.getElementById("deftemp")
|
|
.querySelectorAll("path, clipPath, svg")
|
|
.forEach(el => el.remove());
|
|
document.getElementById("coas").innerHTML = ""; // remove auto-generated emblems
|
|
notes = [];
|
|
rulers = new Rulers();
|
|
unfog();
|
|
}
|