Merge pull request #870 from Azgaar/vite-provinces

Vite provinces
This commit is contained in:
Azgaar 2022-09-19 20:16:25 +03:00 committed by GitHub
commit 2035b12115
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1525 additions and 700 deletions

View file

@ -2206,7 +2206,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 350">
<rect width="100%" height="100%" fill="#005bbb"></rect>
<rect y="50%" width="100%" height="50%" fill="#ffd500"></rect>
<text x="50%" text-anchor="middle" font-size="9em" y="35%" fill="#f5f5f5">Support Ukraine</text>
<text x="50%" text-anchor="middle" font-size="8em" y="32%" fill="#f5f5f5">Support Ukraine</text>
<text x="50%" text-anchor="middle" font-size="4em" y="78%" fill="#005bdd">
war.ukraine.ua/support-ukraine
</text>
@ -2604,8 +2604,8 @@
</div>
<div data-tip="Lake average depth in selected units">
<div class="label">Avarage depth:</div>
<input id="lakeAvarageDepth" disabled />
<div class="label">Average depth:</div>
<input id="lakeAverageDepth" disabled />
</div>
<div data-tip="Lake maximum depth in selected units">
@ -3987,11 +3987,6 @@
></button>
<button id="convertColorsButton" data-tip="Set maximum number of colors" class="icon-signal"></button>
<input id="convertColors" value="100" style="display: none" />
<button
id="convertComplete"
data-tip="Complete the conversion. All unassigned colors will be considered as ocean"
class="icon-check"
></button>
<button
id="convertCancel"
data-tip="Cancel the conversion. Previous heightmap will be restored"
@ -4013,12 +4008,23 @@
</div>
<div data-tip="Select a color to re-assign the height value" id="colorsAssigned" style="display: none">
<i>Assigned colors (<span id="colorsAssignedNumber"></span>):</i><br />
<i>Assigned colors (<span id="colorsAssignedNumber"></span>):</i>
<div id="colorsAssignedContainer" class="colorsContainer"></div>
</div>
<div data-tip="Select a color to assign a height value" id="colorsUnassigned" style="display: none">
<i>Unassigned colors (<span id="colorsUnassignedNumber"></span>):</i><br />
<i>Unassigned colors (<span id="colorsUnassignedNumber"></span>):</i>
<div id="colorsUnassignedContainer" class="colorsContainer"></div>
</div>
<button
id="convertComplete"
data-tip="Complete the conversion. All unassigned colors will be considered as ocean"
style="margin: 0.4em 0"
class="glow"
>
Complete the conversion
</button>
</div>
<div id="biomesEditor" class="dialog stable" style="display: none">
@ -5466,19 +5472,19 @@
<div data-tip="Set map rotation speed. Set to 0 is you want to toggle off the rotation">
<div>Rotation:</div>
<input id="options3dMeshRotationRange" type="range" min="0" max="10" step=".1" />
<input id="options3dMeshRotationNumber" type="number" min="0" max="10" step=".1" style="width: 3em" />
<input id="options3dMeshRotationNumber" type="number" min="0" max="10" step=".1" style="width: 4em" />
</div>
<div data-tip="Set height scale">
<div>Height scale:</div>
<input id="options3dScaleRange" type="range" min="0" max="100" />
<input id="options3dScaleNumber" type="number" min="0" max="1000" style="width: 3em" />
<input id="options3dScaleNumber" type="number" min="0" max="1000" style="width: 4em" />
</div>
<div data-tip="Set scene lightness">
<div>Lightness:</div>
<input id="options3dLightnessRange" type="range" min="0" max="100" />
<input id="options3dLightnessNumber" type="number" min="0" max="500" style="width: 3em" />
<input id="options3dLightnessNumber" type="number" min="0" max="500" style="width: 4em" />
</div>
<div data-tip="Set sun position (x, y, z) to define shadows">
@ -5518,7 +5524,7 @@
<div data-tip="Set globe rotation speed. Set to 0 is you want to toggle off the rotation">
<div>Rotation:</div>
<input id="options3dGlobeRotationRange" type="range" min="0" max="10" step=".1" />
<input id="options3dGlobeRotationNumber" type="number" min="0" max="10" step=".1" style="width: 3em" />
<input id="options3dGlobeRotationNumber" type="number" min="0" max="10" step=".1" style="width: 4em" />
</div>
<div data-tip="Set globe texture resolution">
@ -5595,6 +5601,8 @@
<input id="pngResolutionOutput" data-stored="pngResolution" type="number" min="1" max="8" value="1" />
</div>
<p>Generator uses pop-up window to download files. Please ensure your browser does not block popups.</p>
<div style="margin: 1em 0 0.3em; font-weight: bold">Export to GeoJSON</div>
<div>
<button id="saveGeoJSON_Cells" data-tip="Download cells data in GeoJSON format">cells</button>
@ -5607,7 +5615,6 @@
<a href="https://github.com/Azgaar/Fantasy-Map-Generator/wiki/GIS-data-export" target="_blank">wiki-page</a>
for guidance.
</p>
<p>Generator uses pop-up window to download files. Please ensure your browser does not block popups.</p>
<div style="margin: 1em 0 0.3em; font-weight: bold">Export To JSON</div>
<div>
@ -5622,12 +5629,10 @@
</div>
<p>Export in JSON format can be used as an API replacement.</p>
<div>
<p>
It's also possible to export map to <i>Foundry VTT</i>, see
<a href="https://github.com/Ethck/azgaar-foundry" target="_blank">the module.</a>
</p>
</div>
<p>
It's also possible to export map to <i>Foundry VTT</i>, see
<a href="https://github.com/Ethck/azgaar-foundry" target="_blank">the module.</a>
</p>
</div>
<div id="saveMapData" style="display: none" class="dialog">

View file

@ -15,6 +15,7 @@
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0",
"@types/d3": "^5.9.0",
"@types/d3-array": "^3.0.3",
"@types/delaunator": "^5.0.0",
"@types/jquery": "^3.5.14",
"@types/jqueryui": "^1.12.16",
@ -35,6 +36,7 @@
},
"dependencies": {
"d3": "5.8.0",
"d3-array": "^3.2.0",
"delaunator": "^5.0.0",
"flatqueue": "^2.0.3",
"lineclip": "^1.1.5",

View file

@ -1190,7 +1190,7 @@ export function open(options) {
.on("click", mapClicked);
const colors = pallete.map(p => `rgb(${p[0]}, ${p[1]}, ${p[2]})`);
d3.select("#colorsUnassigned")
d3.select("#colorsUnassignedContainer")
.selectAll("div")
.data(colors)
.enter()
@ -1253,25 +1253,23 @@ export function open(options) {
this.setAttribute("data-height", height);
});
if (selectedColor.parentNode.id === "colorsUnassigned") {
colorsAssigned.appendChild(selectedColor);
if (selectedColor.parentNode.id === "colorsUnassignedContainer") {
colorsAssignedContainer.appendChild(selectedColor);
colorsAssigned.style.display = "block";
byId("colorsUnassignedNumber").innerHTML = colorsUnassigned.childElementCount - 2;
byId("colorsAssignedNumber").innerHTML = colorsAssigned.childElementCount - 2;
byId("colorsUnassignedNumber").innerHTML = colorsUnassignedContainer.childElementCount - 2;
byId("colorsAssignedNumber").innerHTML = colorsAssignedContainer.childElementCount - 2;
}
}
// auto assign color based on luminosity or hue
function autoAssing(type) {
let unassigned = colorsUnassigned.querySelectorAll("div");
let unassigned = colorsUnassignedContainer.querySelectorAll("div");
if (!unassigned.length) {
heightsFromImage(+convertColors.value);
unassigned = colorsUnassigned.querySelectorAll("div");
if (!unassigned.length) {
tip("No unassigned colors. Please load an image and click the button again", false, "error");
return;
}
unassigned = colorsUnassignedContainer.querySelectorAll("div");
if (!unassigned.length)
return tip("No unassigned colors. Please load an image and click the button again", false, "error");
}
const getHeightByHue = function (color) {
@ -1315,18 +1313,18 @@ export function open(options) {
} // if color is already added, remove it
el.style.backgroundColor = el.dataset.color = colorTo;
el.dataset.height = height;
colorsAssigned.appendChild(el);
colorsAssignedContainer.appendChild(el);
assinged[height] = true;
});
// sort assigned colors by height
Array.from(colorsAssigned.children)
Array.from(colorsAssignedContainer.children)
.sort((a, b) => +a.dataset.height - +b.dataset.height)
.forEach(line => colorsAssigned.appendChild(line));
.forEach(line => colorsAssignedContainer.appendChild(line));
colorsAssigned.style.display = "block";
colorsUnassigned.style.display = "none";
byId("colorsAssignedNumber").innerHTML = colorsAssigned.childElementCount - 2;
byId("colorsAssignedNumber").innerHTML = colorsAssignedContainer.childElementCount - 2;
}
function setConvertColorsNumber() {
@ -1346,7 +1344,8 @@ export function open(options) {
}
function applyConversion() {
if (colorsAssigned.childElementCount < 3) return tip("Please do the assignment first", false, "error");
if (colorsAssignedContainer.childElementCount < 3)
return tip("Please assign colors to heights first", false, "error");
viewbox
.select("#heights")

View file

@ -14,6 +14,8 @@ export function open({el}) {
closeDialogs();
if (!layerIsOn("toggleLabels")) toggleLayer("toggleLabels");
const lineGen = d3.line().curve(d3.curveBundle.beta(1));
const textPath = el.parentNode;
const text = textPath.parentNode;
elSelected = d3.select(text).call(d3.drag().on("start", dragLabel)).classed("draggable", true);
@ -123,8 +125,6 @@ export function open({el}) {
redrawLabelPath();
}
const lineGen = d3.line().curve(d3.curveBundle.beta(1));
function redrawLabelPath() {
const path = byId("textPath_" + elSelected.attr("id"));
const points = [];
@ -308,26 +308,12 @@ export function open({el}) {
function changeText() {
const input = byId("labelText").value;
const el = elSelected.select("textPath").node();
const example = d3
.select(elSelected.node().parentNode)
.append("text")
.attr("x", 0)
.attr("x", 0)
.attr("font-size", el.getAttribute("font-size"))
.node();
const lines = input.split("|");
const top = (lines.length - 1) / -2; // y offset
const inner = lines
.map((l, d) => {
example.innerHTML = l;
const left = example.getBBox().width / -2; // x offset
return `<tspan x="${left}px" dy="${d ? 1 : top}em">${l}</tspan>`;
})
.join("");
const inner = lines.map((l, d) => `<tspan x="0" dy="${d ? 1 : top}em">${l}</tspan>`).join("");
el.innerHTML = inner;
example.remove();
if (elSelected.attr("id").slice(0, 10) === "stateLabel")
tip("Use States Editor to change an actual state name, not just a label", false, "warning");

View file

@ -65,7 +65,7 @@ export function open({el}) {
const heights = lakeCells.map(i => cells.h[i]);
byId("lakeElevation").value = getHeight(l.height);
byId("lakeAvarageDepth").value = getHeight(d3.mean(heights), true);
byId("lakeAverageDepth").value = getHeight(d3.mean(heights), true);
byId("lakeMaxDepth").value = getHeight(d3.min(heights), true);
byId("lakeFlux").value = l.flux;

View file

@ -169,6 +169,7 @@ a {
font-size: 0.8em;
}
#provincesBody,
#statesBody {
stroke-width: 3;
}
@ -179,10 +180,6 @@ a {
stroke-linejoin: round;
}
#provincesBody {
stroke-width: 0.2;
}
#statesBody,
#provincesBody,
#relig,
@ -243,7 +240,7 @@ i.icon-lock {
}
#labels {
text-anchor: start;
text-anchor: middle;
dominant-baseline: central;
cursor: pointer;
}
@ -1144,12 +1141,17 @@ div#regimentSelectorBody > div > div {
filter: sepia(1) hue-rotate(200deg);
}
.colorsContainer {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-column-gap: 0.3em;
grid-row-gap: 0.2em;
}
.color-div {
width: 3em;
height: 1em;
display: inline-block;
margin: 0 0.16em;
border: 1px #c5c5c5 groove;
height: 1.5em;
border: 1px #999 solid;
cursor: pointer;
}

View file

@ -1,6 +1,5 @@
import * as d3 from "d3";
import {getProvincesVertices} from "./drawProvinces";
import {minmax, rn} from "utils/numberUtils";
import {byId} from "utils/shorthands";
@ -42,7 +41,7 @@ export function drawEmblems() {
const sizeProvinces = getProvinceEmblemsSize();
const provinceCOAs = validProvinces.map(province => {
if (!province.pole) getProvincesVertices();
if (!province.pole) throw "Pole is not defined";
const [x, y] = province.pole || pack.cells.p[province.center];
const size = province.coaSize || 1;
const shift = (sizeProvinces * size) / 2;

View file

@ -1,15 +1,11 @@
export function drawLabels() {
drawBurgLabels();
// TODO: draw other labels
import * as d3 from "d3";
window.Zoom.invoke();
}
function drawBurgLabels() {
export function drawBurgLabels(burgs: TBurgs) {
// remove old data
const burgLabels = d3.select("#burgLabels");
burgLabels.selectAll("text").remove();
const validBurgs = pack.burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[];
const validBurgs = burgs.filter(burg => burg.i && !(burg as IBurg).removed) as IBurg[];
// capitals
const capitals = validBurgs.filter(burg => burg.capital);

View file

@ -0,0 +1,306 @@
import * as d3 from "d3";
import {findCell} from "utils/graphUtils";
import {isLake, isState} from "utils/typeUtils";
import {round, splitInTwo} from "utils/stringUtils";
import {minmax, rn} from "utils/numberUtils";
// increase step to 15 or 30 to make it faster and more horyzontal
// decrease step to 5 to improve accuracy
const ANGLE_STEP = 9;
const raycast = precalculateAngles(ANGLE_STEP);
const INITIAL_DISTANCE = 10;
const DISTANCE_STEP = 15;
const MAX_ITERATIONS = 100;
export function drawStateLabels(
features: TPackFeatures,
featureIds: Uint16Array,
stateIds: Uint16Array,
states: TStates
) {
/* global: findCell, graphWidth, graphHeight */
console.time("drawStateLabels");
const labelPaths = getLabelPaths(features, featureIds, stateIds, states);
drawLabelPath(stateIds, states, labelPaths);
console.timeEnd("drawStateLabels");
}
function getLabelPaths(features: TPackFeatures, featureIds: Uint16Array, stateIds: Uint16Array, states: TStates) {
const labelPaths: [number, TPoints][] = [];
for (const state of states) {
if (!isState(state)) continue;
const offset = getOffsetWidth(state.cells);
const maxLakeSize = state.cells / 50;
const [x0, y0] = state.pole;
const offsetPoints = new Map(
(offset ? raycast : []).map(({angle, x: x1, y: y1}) => {
const [x, y] = [x0 + offset * x1, y0 + offset * y1];
return [angle, {x, y}];
})
);
const distances = raycast.map(({angle, x: dx, y: dy, modifier}) => {
let distanceMin: number;
const distance1 = getMaxDistance(state.i, {x: x0, y: y0}, dx, dy, maxLakeSize);
if (offset) {
const point2 = offsetPoints.get(angle - 90 < 0 ? angle + 270 : angle - 90)!;
const distance2 = getMaxDistance(state.i, point2, dx, dy, maxLakeSize);
const point3 = offsetPoints.get(angle + 90 >= 360 ? angle - 270 : angle + 90)!;
const distance3 = getMaxDistance(state.i, point3, dx, dy, maxLakeSize);
distanceMin = Math.min(distance1, distance2, distance3);
} else {
distanceMin = distance1;
}
const [x, y] = [x0 + distanceMin * dx, y0 + distanceMin * dy];
return {angle, distance: distanceMin * modifier, x, y};
});
const {
angle,
x: x1,
y: y1
} = distances.reduce(
(acc, {angle, distance, x, y}) => {
if (distance > acc.distance) return {angle, distance, x, y};
return acc;
},
{angle: 0, distance: 0, x: 0, y: 0}
);
const oppositeAngle = angle >= 180 ? angle - 180 : angle + 180;
const {x: x2, y: y2} = distances.reduce(
(acc, {angle, distance, x, y}) => {
const angleDif = getAnglesDif(angle, oppositeAngle);
const score = distance * getAngleModifier(angleDif);
if (score > acc.score) return {angle, score, x, y};
return acc;
},
{angle: 0, score: 0, x: 0, y: 0}
);
const pathPoints: TPoints = [[x1, y1], state.pole, [x2, y2]];
if (x1 > x2) pathPoints.reverse();
labelPaths.push([state.i, pathPoints]);
}
return labelPaths;
function getMaxDistance(stateId: number, point: {x: number; y: number}, dx: number, dy: number, maxLakeSize: number) {
let distance = INITIAL_DISTANCE;
for (let i = 0; i < MAX_ITERATIONS; i++) {
const [x, y] = [point.x + distance * dx, point.y + distance * dy];
const cellId = findCell(x, y, DISTANCE_STEP);
// drawPoint([x, y], {color: cellId && isPassable(cellId) ? "blue" : "red", radius: 0.8});
if (!cellId || !isPassable(cellId)) break;
distance += DISTANCE_STEP;
}
return distance;
function isPassable(cellId: number) {
const feature = features[featureIds[cellId]];
if (isLake(feature) && feature.cells <= maxLakeSize) return true;
return stateIds[cellId] === stateId;
}
}
}
function drawLabelPath(stateIds: Uint16Array, states: TStates, labelPaths: [number, TPoints][]) {
const mode = options.stateLabelsMode || "auto";
const lineGen = d3.line().curve(d3.curveBundle.beta(1));
const textGroup = d3.select("g#labels > g#states");
const pathGroup = d3.select("defs > g#deftemp > g#textPaths");
const testLabel = textGroup.append("text").attr("x", 0).attr("x", 0).text("Example");
const letterLength = testLabel.node()!.getComputedTextLength() / 7; // approximate length of 1 letter
testLabel.remove();
for (const [stateId, pathPoints] of labelPaths) {
const state = states[stateId];
if (!isState(state)) throw new Error("State must not be neutral");
if (pathPoints.length < 2) throw new Error("Label path must have at least 2 points");
textGroup.select("#textPath_stateLabel" + stateId).remove();
pathGroup.select("#stateLabel" + stateId).remove();
const textPath = pathGroup
.append("path")
.attr("d", round(lineGen(pathPoints)!))
.attr("id", "textPath_stateLabel" + stateId);
// drawPath(round(lineGen(pathPoints)!), {stroke: "red", strokeWidth: 1});
const pathLength = textPath.node()!.getTotalLength() / letterLength; // path length in letters
const [lines, ratio] = getLinesAndRatio(mode, state.name, state.fullName, pathLength);
// prolongate path if it's too short
const longestLineLength = d3.max(lines.map(({length}) => length))!;
if (pathLength && pathLength < longestLineLength) {
const [x1, y1] = pathPoints.at(0)!;
const [x2, y2] = pathPoints.at(-1)!;
const [dx, dy] = [(x2 - x1) / 2, (y2 - y1) / 2];
const mod = longestLineLength / pathLength;
pathPoints[0] = [x1 + dx - dx * mod, y1 + dy - dy * mod];
pathPoints[pathPoints.length - 1] = [x2 - dx + dx * mod, y2 - dy + dy * mod];
textPath.attr("d", round(lineGen(pathPoints)!));
// drawPath(round(lineGen(pathPoints)!), {stroke: "blue", strokeWidth: 0.4});
}
const textElement = textGroup
.append("text")
.attr("id", "stateLabel" + stateId)
.append("textPath")
.attr("startOffset", "50%")
.attr("font-size", ratio + "%")
.node()!;
const top = (lines.length - 1) / -2; // y offset
const spans = lines.map((line, index) => `<tspan x="0" dy="${index ? 1 : top}em">${line}</tspan>`);
textElement.insertAdjacentHTML("afterbegin", spans.join(""));
const {width, height} = textElement.getBBox();
textElement.setAttribute("href", "#textPath_stateLabel" + stateId);
if (mode === "full" || lines.length === 1) continue;
// check if label fits state boundaries. If no, replace it with short name
const [[x1, y1], [x2, y2]] = [pathPoints.at(0)!, pathPoints.at(-1)!];
const angleRad = Math.atan2(y2 - y1, x2 - x1);
const isInsideState = checkIfInsideState(textElement, angleRad, width / 2, height / 2, stateIds, stateId);
if (isInsideState) continue;
// replace name to one-liner
const text = pathLength > state.fullName.length * 1.8 ? state.fullName : state.name;
textElement.innerHTML = `<tspan x="0">${text}</tspan>`;
const correctedRatio = minmax(rn((pathLength / text.length) * 50), 40, 130);
textElement.setAttribute("font-size", correctedRatio + "%");
// textElement.setAttribute("fill", "blue");
}
}
// point offset to reduce label overlap with state borders
function getOffsetWidth(cellsNumber: number) {
if (cellsNumber < 80) return 0;
if (cellsNumber < 140) return 5;
if (cellsNumber < 200) return 15;
if (cellsNumber < 300) return 20;
if (cellsNumber < 500) return 25;
return 30;
}
// difference between two angles in range [0, 180]
function getAnglesDif(angle1: number, angle2: number) {
return 180 - Math.abs(Math.abs(angle1 - angle2) - 180);
}
// score multiplier based on angle difference betwee left and right sides
function getAngleModifier(angleDif: number) {
if (angleDif === 0) return 1;
if (angleDif <= 15) return 0.95;
if (angleDif <= 30) return 0.9;
if (angleDif <= 45) return 0.6;
if (angleDif <= 60) return 0.3;
if (angleDif <= 90) return 0.1;
return 0; // >90
}
function precalculateAngles(step: number) {
const angles = [];
const RAD = Math.PI / 180;
for (let angle = 0; angle < 360; angle += step) {
const x = Math.cos(angle * RAD);
const y = Math.sin(angle * RAD);
const angleDif = 90 - Math.abs((angle % 180) - 90);
const modifier = 1 - angleDif / 120; // [0.25, 1]
angles.push({angle, modifier, x, y});
}
return angles;
}
function getLinesAndRatio(
mode: "auto" | "short" | "full",
name: string,
fullName: string,
pathLength: number
): [string[], number] {
// short name
if (mode === "short" || (mode === "auto" && pathLength <= name.length)) {
const lines = splitInTwo(name);
const longestLineLength = d3.max(lines.map(({length}) => length))!;
const ratio = pathLength / longestLineLength;
return [lines, minmax(rn(ratio * 60), 50, 150)];
}
// full name: one line
if (pathLength > fullName.length * 2) {
const lines = [fullName];
const ratio = pathLength / lines[0].length;
return [lines, minmax(rn(ratio * 70), 70, 170)];
}
// full name: two lines
const lines = splitInTwo(fullName);
const longestLineLength = d3.max(lines.map(({length}) => length))!;
const ratio = pathLength / longestLineLength;
return [lines, minmax(rn(ratio * 60), 70, 150)];
}
// check whether multi-lined label is mostly inside the state. If no, replace it with short name label
function checkIfInsideState(
textElement: SVGTextPathElement,
angleRad: number,
halfwidth: number,
halfheight: number,
stateIds: Uint16Array,
stateId: number
) {
const bbox = textElement.getBBox();
const [cx, cy] = [bbox.x + bbox.width / 2, bbox.y + bbox.height / 2];
const points: TPoints = [
[-halfwidth, -halfheight],
[+halfwidth, -halfheight],
[+halfwidth, halfheight],
[-halfwidth, halfheight],
[0, halfheight],
[0, -halfheight]
];
const sin = Math.sin(angleRad);
const cos = Math.cos(angleRad);
const rotatedPoints: TPoints = points.map(([x, y]) => [cx + x * cos - y * sin, cy + x * sin + y * cos]);
// drawPolyline([...rotatedPoints.slice(0, 4), rotatedPoints[0]], {stroke: "#333"});
let pointsInside = 0;
for (const [x, y] of rotatedPoints) {
const isInside = stateIds[findCell(x, y)] === stateId;
if (isInside) pointsInside++;
// drawPoint([x, y], {color: isInside ? "green" : "red"});
if (pointsInside > 4) return true;
}
return false;
}

View file

@ -0,0 +1,12 @@
import {drawBurgLabels} from "./drawBurgLabels";
import {drawStateLabels} from "./drawStateLabels";
export function drawLabels() {
/* global */ const {cells, features, states, burgs} = pack;
drawStateLabels(features, cells.f, cells.state, states);
drawBurgLabels(burgs);
// TODO: draw other labels
window.Zoom.invoke();
}

View file

@ -1,122 +0,0 @@
import polylabel from "polylabel";
export function drawProvinces() {
const labelsOn = provs.attr("data-labels") == 1;
provs.selectAll("*").remove();
const provinces = pack.provinces;
const {body, gap} = getProvincesVertices();
const g = provs.append("g").attr("id", "provincesBody");
const bodyData = body.map((p, i) => [p.length > 10 ? p : null, i, provinces[i].color]).filter(d => d[0]);
g.selectAll("path")
.data(bodyData)
.enter()
.append("path")
.attr("d", d => d[0])
.attr("fill", d => d[2])
.attr("stroke", "none")
.attr("id", d => "province" + d[1]);
const gapData = gap.map((p, i) => [p.length > 10 ? p : null, i, provinces[i].color]).filter(d => d[0]);
g.selectAll(".path")
.data(gapData)
.enter()
.append("path")
.attr("d", d => d[0])
.attr("fill", "none")
.attr("stroke", d => d[2])
.attr("id", d => "province-gap" + d[1]);
const labels = provs.append("g").attr("id", "provinceLabels");
labels.style("display", `${labelsOn ? "block" : "none"}`);
const labelData = provinces.filter(p => p.i && !p.removed && p.pole);
labels
.selectAll(".path")
.data(labelData)
.enter()
.append("text")
.attr("x", d => d.pole[0])
.attr("y", d => d.pole[1])
.attr("id", d => "provinceLabel" + d.i)
.text(d => d.name);
}
export function getProvincesVertices() {
const cells = pack.cells,
vertices = pack.vertices,
provinces = pack.provinces,
n = cells.i.length;
const used = new Uint8Array(cells.i.length);
const vArray = new Array(provinces.length); // store vertices array
const body = new Array(provinces.length).fill(""); // store path around each province
const gap = new Array(provinces.length).fill(""); // store path along water for each province to fill the gaps
for (const i of cells.i) {
if (!cells.province[i] || used[i]) continue;
const p = cells.province[i];
const onborder = cells.c[i].some(n => cells.province[n] !== p);
if (!onborder) continue;
const borderWith = cells.c[i].map(c => cells.province[c]).find(n => n !== p);
const vertex = cells.v[i].find(v => vertices.c[v].some(i => cells.province[i] === borderWith));
const chain = connectVertices(vertex, p, borderWith);
if (chain.length < 3) continue;
const points = chain.map(v => vertices.p[v[0]]);
if (!vArray[p]) vArray[p] = [];
vArray[p].push(points);
body[p] += "M" + points.join("L");
gap[p] +=
"M" +
vertices.p[chain[0][0]] +
chain.reduce(
(r, v, i, d) =>
!i ? r : !v[2] ? r + "L" + vertices.p[v[0]] : d[i + 1] && !d[i + 1][2] ? r + "M" + vertices.p[v[0]] : r,
""
);
}
// find province visual center
vArray.forEach((ar, i) => {
const sorted = ar.sort((a, b) => b.length - a.length); // sort by points number
provinces[i].pole = polylabel(sorted, 1.0); // pole of inaccessibility
});
return {body, gap};
// connect vertices to chain
function connectVertices(start, t, province) {
const chain = []; // vertices chain to form a path
let land = vertices.c[start].some(c => cells.h[c] >= 20 && cells.province[c] !== t);
function check(i) {
province = cells.province[i];
land = cells.h[i] >= 20;
}
for (let i = 0, current = start; i === 0 || (current !== start && i < 20000); i++) {
const prev = chain[chain.length - 1] ? chain[chain.length - 1][0] : -1; // previous vertex in chain
chain.push([current, province, land]); // add current vertex to sequence
const c = vertices.c[current]; // cells adjacent to vertex
c.filter(c => cells.province[c] === t).forEach(c => (used[c] = 1));
const c0 = c[0] >= n || cells.province[c[0]] !== t;
const c1 = c[1] >= n || cells.province[c[1]] !== t;
const c2 = c[2] >= n || cells.province[c[2]] !== t;
const v = vertices.v[current]; // neighboring vertices
if (v[0] !== prev && c0 !== c1) {
current = v[0];
check(c0 ? c[0] : c[1]);
} else if (v[1] !== prev && c1 !== c2) {
current = v[1];
check(c1 ? c[1] : c[2]);
} else if (v[2] !== prev && c0 !== c2) {
current = v[2];
check(c2 ? c[2] : c[0]);
}
if (current === chain[chain.length - 1][0]) {
ERROR && console.error("Next vertex is not found");
break;
}
}
chain.push([start, province, land]); // add starting vertex to sequence to close the path
return chain;
}
}

View file

@ -0,0 +1,45 @@
import {pick} from "utils/functionUtils";
import {byId} from "utils/shorthands";
import {isProvince} from "utils/typeUtils";
import {getPaths} from "./utils/getVertexPaths";
export function drawProvinces() {
/* global */ const {cells, vertices, features, provinces} = pack;
const paths = getPaths({
getType: (cellId: number) => cells.province[cellId],
cells: pick(cells, "c", "v", "b", "h", "f"),
vertices,
features,
options: {fill: true, waterGap: true, halo: false}
});
const getColor = (i: string) => (provinces[Number(i)] as IProvince).color;
const getLabels = () => {
const renderLabels = byId("provs")!.getAttribute("data-labels") === "1";
if (!renderLabels) return [];
return provinces.filter(isProvince).map(({i, pole: [x, y], name}) => {
return `<text x="${x}" y="${y}" id="provinceLabel${i}">${name}</text>`;
});
};
const htmlPaths = paths.map(([index, {fill, waterGap}]) => {
const color = getColor(index);
return /* html */ `
<path d="${waterGap}" fill="none" stroke="${color}" id="province-gap${index}" />
<path d="${fill}" fill="${color}" stroke="none" id="province${index}" />
`;
});
byId("provs")!.innerHTML = /* html*/ `
<g id="provincesBody">
${htmlPaths.join("")}
</g>
<g id="provinceLabels">
${getLabels().join("")}
</g>
`;
}

View file

@ -833,7 +833,7 @@ window.BurgsAndStates = (function () {
valid.forEach(s => (s.diplomacy = new Array(states.length).fill("x"))); // clear all relationships
if (valid.length < 2) return; // no states to renerate relations with
const areaMean = d3.mean(valid.map(s => s.area)); // avarage state area
const areaMean = d3.mean(valid.map(s => s.area)); // average state area
// generic relations
for (let f = 1; f < states.length; f++) {

View file

@ -75,10 +75,8 @@ export function editNamesbase() {
function updateInputs() {
const base = +document.getElementById("namesbaseSelect").value;
if (!nameBases[base]) {
tip(`Namesbase ${base} is not defined`, false, "error");
return;
}
if (!nameBases[base]) return tip(`Namesbase ${base} is not defined`, false, "error");
document.getElementById("namesbaseTextarea").value = nameBases[base].b;
document.getElementById("namesbaseName").value = nameBases[base].name;
document.getElementById("namesbaseMin").value = nameBases[base].min;
@ -104,20 +102,23 @@ export function editNamesbase() {
function updateNamesData() {
const base = +document.getElementById("namesbaseSelect").value;
const b = document.getElementById("namesbaseTextarea").value;
if (b.split(",").length < 3) {
tip("The names data provided is too short of incorrect", false, "error");
return;
}
nameBases[base].b = b;
const rawInput = document.getElementById("namesbaseTextarea").value;
if (rawInput.split(",").length < 3) return tip("The names data provided is too short of incorrect", false, "error");
const namesData = rawInput.replace(/[/|]/g, "");
nameBases[base].b = namesData;
Names.updateChain(base);
}
function updateBaseName() {
const base = +document.getElementById("namesbaseSelect").value;
const select = document.getElementById("namesbaseSelect");
select.options[namesbaseSelect.selectedIndex].innerHTML = this.value;
nameBases[base].name = this.value;
const rawName = this.value;
const name = rawName.replace(/[/|]/g, "");
select.options[namesbaseSelect.selectedIndex].innerHTML = name;
nameBases[base].name = name;
}
function updateBaseMin() {

View file

@ -485,10 +485,17 @@ function changeDialogsTheme(themeColor, transparency) {
}
function changeZoomExtent(value) {
const min = Math.max(+byId("zoomExtentMin").value, 0.01);
const max = Math.min(+byId("zoomExtentMax").value, 200);
Zoom.scaleExtent([min, max]);
const zoomExtentMin = byId("zoomExtentMin");
const zoomExtentMax = byId("zoomExtentMax");
if (+zoomExtentMin.value > +zoomExtentMax.value) {
[zoomExtentMin.value, zoomExtentMax.value] = [zoomExtentMax.value, zoomExtentMin.value];
}
const min = Math.max(+zoomExtentMin.value, 0.01);
const max = Math.min(+zoomExtentMax.value, 200);
zoomExtentMin.value = min;
zoomExtentMax.value = max;
zoom.scaleExtent([min, max]);
const scale = minmax(+value, 0.01, 200);
Zoom.scaleTo(svg, scale);
}
@ -1056,6 +1063,7 @@ export function toggle3dOptions() {
const globe = byId("canvas3d").dataset.type === "viewGlobe";
options3dMesh.style.display = globe ? "none" : "block";
options3dGlobe.style.display = globe ? "block" : "none";
options3dOBJSave.style.display = globe ? "none" : "inline-block";
options3dScaleRange.value = options3dScaleNumber.value = ThreeD.options.scale;
options3dLightnessRange.value = options3dLightnessNumber.value = ThreeD.options.lightness * 100;
options3dSunX.value = ThreeD.options.sun.x;

View file

@ -67,10 +67,10 @@ async function generate(options?: IGenerationOptions) {
// renderLayer("heightmap");
// renderLayer("rivers");
// renderLayer("biomes");
renderLayer("burgs");
renderLayer("routes");
// renderLayer("burgs");
// renderLayer("routes");
renderLayer("states");
// renderLayer("religions");
renderLayer("labels");
// pack.cells.route.forEach((route, index) => {
// if (route === 2) drawPoint(pack.cells.p[index], {color: "black"});

View file

@ -97,12 +97,15 @@ export const adjectivalForms = [
"Theocracy",
"Oligarchy",
"Union",
"Federation",
"Confederation",
"Trade Company",
"League",
"Tetrarchy",
"Triumvirate",
"Diarchy",
"Khanate",
"Khaganate",
"Horde",
"Marches"
];

View file

@ -40,7 +40,7 @@ export function defineStateForm(
const generic = {Monarchy: 25, Republic: 2, Union: 1};
const naval = {Monarchy: 6, Republic: 2, Union: 1};
function defineForm(type: TCultureType, areaTier: AreaTiers) {
function defineForm(type: TCultureType, areaTier: AreaTiers): TStateForm {
const isAnarchy = P((1 - areaTier / 5) / 100); // [1% - 0.2%] chance
if (isAnarchy) return "Anarchy";

View file

@ -6,6 +6,42 @@ import {minmax} from "utils/numberUtils";
import {ELEVATION, FOREST_BIOMES, MIN_LAND_HEIGHT, DISTANCE_FIELD} from "config/generation";
import type {TStateData} from "./createStateData";
const costs = {
SAME_CULTURE: -9,
DIFFERENT_CULTURES: 100,
MAX_SUITABILITY: 20,
UNINHABITED_LAND: 5000,
NATIVE_BIOME_FIXED: 10,
GENERIC_WATER_CROSSING: 1000,
NOMADS_WATER_CROSSING: 10000,
NAVAL_WATER_CROSSING: 300,
LAKE_STATES_LAKE_CROSSING: 10,
GENERIC_MOUNTAINS_CROSSING: 2200,
GENERIC_HILLS_CROSSING: 300,
HIGHLAND_STATE_LOWLANDS: 1100,
HIGHLAND_STATE_HIGHTLAND: 0,
RIVER_STATE_RIVER_CROSSING: 0,
RIVER_STATE_NO_RIVER: 100,
RIVER_CROSSING_MIN: 20,
RIVER_CROSSING_MAX: 100,
GENERIC_LAND_COAST: 20,
MARITIME_LAND_COAST: 0,
NOMADS_LAND_COAST: 60,
GENERIC_LANDLOCKED: 0,
NAVAL_LANDLOCKED: 30
};
const multipliers = {
HUNTERS_NON_NATIVE_BIOME: 2,
NOMADS_FOREST_BIOMES: 3,
GENERIC_NON_NATIVE_BIOME: 1,
GENERIC_DEEP_WATER: 2
};
// growth algorithm to assign cells to states
export function expandStates(
capitalCells: Map<number, boolean>,
@ -30,39 +66,6 @@ export function expandStates(
queue.push({cellId, stateId}, 0);
}
// expansion costs (less is better)
const SAME_CULTURE_BONUS = -9;
const DIFFERENT_CULTURES_FEE = 100;
const MAX_SUITABILITY_COST = 20;
const UNINHABITED_LAND_FEE = 5000;
const NATIVE_BIOME_FIXED_COST = 10;
const HUNTERS_NON_NATIVE_BIOME_FEE_MULTIPLIER = 2;
const NOMADS_FOREST_BIOMES_FEE_MULTIPLIER = 3;
const GENERIC_NON_NATIVE_BIOME_FEE_MULTIPLIER = 1;
const GENERIC_DEEP_WATER_FEE_MULTIPLIER = 2;
const GENERIC_WATER_CROSSING_FEE = 1000;
const NOMADS_WATER_CROSSING_FEE = 10000;
const NAVAL_WATER_CROSSING_FEE = 300;
const LAKE_STATES_LAKE_CROSSING_FEE = 10;
const GENERIC_MOUNTAINS_CROSSING_FEE = 2200;
const GENERIC_HILLS_CROSSING_FEE = 300;
const HIGHLAND_STATE_LOWLANDS_FEE = 1100;
const HIGHLAND_STATE_HIGHTLAND_COST = 0;
const RIVER_STATE_RIVER_CROSSING_COST = 0;
const RIVER_STATE_NO_RIVER_COST = 100;
const RIVER_CROSSING_MIN_COST = 20;
const RIVER_CROSSING_MAX_COST = 100;
const GENERIC_LAND_COAST_FEE = 20;
const MARITIME_LAND_COAST_FEE = 0;
const NOMADS_LAND_COAST_FEE = 60;
const GENERIC_LANDLOCKED_FEE = 0;
const NAVAL_LANDLOCKED_FEE = 30;
const statesMap = new Map<number, TStateData>(statesData.map(stateData => [stateData.i, stateData]));
while (queue.length) {
@ -100,7 +103,7 @@ export function expandStates(
return normalizeStates(stateIds, capitalCells, cells.c, cells.h);
function getCultureCost(cellId: number, stateCulture: number) {
return cells.culture[cellId] === stateCulture ? SAME_CULTURE_BONUS : DIFFERENT_CULTURES_FEE;
return cells.culture[cellId] === stateCulture ? costs.SAME_CULTURE : costs.DIFFERENT_CULTURES;
}
function getPopulationCost(cellId: number) {
@ -108,19 +111,19 @@ export function expandStates(
if (isWater) return 0;
const suitability = cells.s[cellId];
if (suitability) return Math.max(MAX_SUITABILITY_COST - suitability, 0);
if (suitability) return Math.max(costs.MAX_SUITABILITY - suitability, 0);
return UNINHABITED_LAND_FEE;
return costs.UNINHABITED_LAND;
}
function getBiomeCost(cellId: number, capitalBiome: number, type: TCultureType) {
const biome = cells.biome[cellId];
if (biome === capitalBiome) return NATIVE_BIOME_FIXED_COST;
if (biome === capitalBiome) return costs.NATIVE_BIOME_FIXED;
const defaultCost = biomesData.cost[biome];
if (type === "Hunting") return defaultCost * HUNTERS_NON_NATIVE_BIOME_FEE_MULTIPLIER;
if (type === "Nomadic" && FOREST_BIOMES.includes(biome)) return defaultCost * NOMADS_FOREST_BIOMES_FEE_MULTIPLIER;
return defaultCost * GENERIC_NON_NATIVE_BIOME_FEE_MULTIPLIER;
if (type === "Hunting") return defaultCost * multipliers.HUNTERS_NON_NATIVE_BIOME;
if (type === "Nomadic" && FOREST_BIOMES.includes(biome)) return defaultCost * multipliers.NOMADS_FOREST_BIOMES;
return defaultCost * multipliers.GENERIC_NON_NATIVE_BIOME;
}
function getHeightCost(cellId: number, type: TCultureType) {
@ -131,12 +134,12 @@ export function expandStates(
const feature = features[cells.f[cellId]];
if (feature === 0) throw new Error(`No feature for cell ${cellId}`);
const isDeepWater = cells.t[cellId] > DISTANCE_FIELD.WATER_COAST;
const multiplier = isDeepWater ? GENERIC_DEEP_WATER_FEE_MULTIPLIER : 1;
const multiplier = isDeepWater ? multipliers.GENERIC_DEEP_WATER : 1;
if (type === "Lake" && feature.type === "lake") return LAKE_STATES_LAKE_CROSSING_FEE * multiplier;
if (type === "Naval") return NAVAL_WATER_CROSSING_FEE * multiplier;
if (type === "Nomadic") return NOMADS_WATER_CROSSING_FEE * multiplier;
return GENERIC_WATER_CROSSING_FEE * multiplier;
if (type === "Lake" && feature.type === "lake") return costs.LAKE_STATES_LAKE_CROSSING * multiplier;
if (type === "Naval") return costs.NAVAL_WATER_CROSSING * multiplier;
if (type === "Nomadic") return costs.NOMADS_WATER_CROSSING * multiplier;
return costs.GENERIC_WATER_CROSSING * multiplier;
}
const isLowlands = height <= ELEVATION.FOOTHILLS;
@ -144,22 +147,22 @@ export function expandStates(
const isMountains = height >= ELEVATION.MOUNTAINS;
if (type === "Highland") {
if (isLowlands) return HIGHLAND_STATE_LOWLANDS_FEE;
return HIGHLAND_STATE_HIGHTLAND_COST;
if (isLowlands) return costs.HIGHLAND_STATE_LOWLANDS;
return costs.HIGHLAND_STATE_HIGHTLAND;
}
if (isMountains) return GENERIC_MOUNTAINS_CROSSING_FEE;
if (isHills) return GENERIC_HILLS_CROSSING_FEE;
if (isMountains) return costs.GENERIC_MOUNTAINS_CROSSING;
if (isHills) return costs.GENERIC_HILLS_CROSSING;
return 0;
}
function getRiverCost(cellId: number, type: TCultureType) {
const isRiver = cells.r[cellId] !== 0;
if (type === "River") return isRiver ? RIVER_STATE_RIVER_CROSSING_COST : RIVER_STATE_NO_RIVER_COST;
if (type === "River") return isRiver ? costs.RIVER_STATE_RIVER_CROSSING : costs.RIVER_STATE_NO_RIVER;
if (!isRiver) return 0;
const flux = cells.fl[cellId];
return minmax(flux / 10, RIVER_CROSSING_MIN_COST, RIVER_CROSSING_MAX_COST);
return minmax(flux / 10, costs.RIVER_CROSSING_MIN, costs.RIVER_CROSSING_MAX);
}
function getTypeCost(cellId: number, type: TCultureType) {
@ -168,15 +171,15 @@ export function expandStates(
const isLandCoast = t === DISTANCE_FIELD.LAND_COAST;
if (isLandCoast) {
if (isMaritime) return MARITIME_LAND_COAST_FEE;
if (type === "Nomadic") return NOMADS_LAND_COAST_FEE;
return GENERIC_LAND_COAST_FEE;
if (isMaritime) return costs.MARITIME_LAND_COAST;
if (type === "Nomadic") return costs.NOMADS_LAND_COAST;
return costs.GENERIC_LAND_COAST;
}
const isLandlocked = t === DISTANCE_FIELD.LANDLOCKED;
if (isLandlocked) {
if (type === "Naval") return NAVAL_LANDLOCKED_FEE;
return GENERIC_LANDLOCKED_FEE;
if (type === "Naval") return costs.NAVAL_LANDLOCKED;
return costs.GENERIC_LANDLOCKED;
}
return 0;
@ -193,7 +196,7 @@ function normalizeStates(
const normalizedStateIds = Uint16Array.from(stateIds);
for (let cellId = 0; cellId > heights.length; cellId++) {
for (let cellId = 0; cellId < heights.length; cellId++) {
if (heights[cellId] < MIN_LAND_HEIGHT) continue;
const neibs = neibCells[cellId].filter(neib => heights[neib] >= MIN_LAND_HEIGHT);

View file

@ -1,4 +1,5 @@
import {WARN} from "config/logging";
import {getPolesOfInaccessibility} from "scripts/getPolesOfInaccessibility";
import {pick} from "utils/functionUtils";
import {getInputNumber} from "utils/nodeUtils";
import {collectStatistics} from "./collectStatistics";
@ -70,7 +71,13 @@ export function generateBurgsAndStates(
const statistics = collectStatistics({...cells, state: stateIds, burg: burgIds}, burgs);
const diplomacy = generateRelations(statesData, statistics, pick(cells, "f"));
const {states, conflicts} = specifyStates(statesData, statistics, diplomacy, cultures, burgs);
const poles = getPolesOfInaccessibility({
vertices,
getType: (cellId: number) => stateIds[cellId],
cellNeighbors: cells.c,
cellVertices: cells.v
});
const {states, conflicts} = specifyStates(statesData, statistics, diplomacy, poles, cultures, burgs);
return {burgIds, stateIds, burgs, states, conflicts};

View file

@ -32,24 +32,27 @@ export function specifyBurgs(
const burgs = [...capitals, ...towns].map((burgData, index) => {
const {cell, culture, capital} = burgData;
const isCapital = Boolean(capital);
const state = stateIds[cell];
const port = definePort(cell, capital);
const population = definePopulation(cell, capital, port);
const port = definePort(cell, isCapital);
const population = definePopulation(cell, isCapital, port);
const [x, y] = defineLocation(cell, port);
const type = defineType(cell, port, population);
const stateData = stateDataMap.get(state)!;
const coa: ICoa = defineEmblem(culture, port, capital, type, cultures, stateData);
const coa: ICoa = defineEmblem(culture, port, isCapital, type, cultures, stateData);
const burg: IBurg = {i: index + 1, ...burgData, state, port, population, x, y, type, coa};
const features = defineFeatures(population, isCapital);
const burg: IBurg = {i: index + 1, ...burgData, state, port, population, x, y, type, coa, ...features};
return burg;
});
TIME && console.timeEnd("specifyBurgs");
return [NO_BURG, ...burgs];
function definePort(cellId: number, capital: Logical) {
function definePort(cellId: number, isCapital: boolean) {
if (!cells.haven[cellId]) return 0; // must be a coastal cell
if (temp[cells.g[cellId]] <= 0) return 0; // temperature must be above zero °C
@ -59,17 +62,17 @@ export function specifyBurgs(
if (feature.cells < 2) return 0; // water body must have at least 2 cells
const isSafeHarbor = cells.harbor[cellId] === 1;
if (!capital && !isSafeHarbor) return 0; // must be a capital or safe harbor
if (!isCapital && !isSafeHarbor) return 0; // must be a capital or safe harbor
return havenFeatureId;
}
// get population in points, where 1 point = 1000 people by default
function definePopulation(cellId: number, capital: Logical, port: number) {
function definePopulation(cellId: number, isCapital: boolean, port: number) {
const basePopulation = cells.s[cellId] / 4;
const decimalPart = (cellId % 1000) / 1000;
const capitalMultiplier = capital ? 1.3 : 1;
const capitalMultiplier = isCapital ? 1.3 : 1;
const portMultiplier = port ? 1.3 : 1;
const randomMultiplier = gauss(1, 1.5, 0.3, 10, 3);
@ -123,12 +126,12 @@ export function specifyBurgs(
function defineEmblem(
cultureId: number,
port: number,
capital: Logical,
isCapital: boolean,
type: TCultureType,
cultures: TCultures,
stateData: TStateData
) {
const coaType = capital && P(0.2) ? "Capital" : type === "Generic" ? "City" : type;
const coaType = isCapital && P(0.2) ? "Capital" : type === "Generic" ? "City" : type;
const cultureShield = cultures[cultureId].shield;
if (!stateData) {
@ -147,11 +150,29 @@ export function specifyBurgs(
function defineKinshipToStateEmblem() {
const baseKinship = 0.25;
const capitalModifier = capital ? 0.1 : 0;
const capitalModifier = isCapital ? 0.1 : 0;
const portModifier = port ? -0.1 : 0;
const cultureModifier = cultureId === stateCultureId ? 0 : -0.25;
return baseKinship + capitalModifier + portModifier + cultureModifier;
}
}
// burg features used mainly in MFCG
function defineFeatures(population: number, isCapital: boolean) {
const citadel: Logical = isCapital || (population > 50 && P(0.75)) || P(0.5) ? 1 : 0;
const plaza: Logical =
population > 50 || (population > 30 && P(0.75)) || (population > 10 && P(0.5)) || P(0.25) ? 1 : 0;
const walls: Logical =
isCapital || population > 30 || (population > 20 && P(0.75)) || (population > 10 && P(0.5)) || P(0.2) ? 1 : 0;
const shanty: Logical =
population > 60 || (population > 40 && P(0.75)) || (population > 20 && walls && P(0.4)) ? 1 : 0;
const temple: Logical = population > 50 || (population > 35 && P(0.75)) || (population > 20 && P(0.5)) ? 1 : 0;
return {citadel, plaza, walls, shanty, temple};
}
}

View file

@ -14,6 +14,7 @@ export function specifyStates(
statesData: TStateData[],
statistics: TStateStatistics,
diplomacy: TDiplomacy,
poles: Dict<TPoint>,
cultures: TCultures,
burgs: TBurgs
): {states: TStates; conflicts: IConflict[]} {
@ -41,6 +42,8 @@ export function specifyStates(
const name = defineStateName(center, capitalName, nameBase, formName);
const fullName = defineFullStateName(name, formName);
const pole = poles[i];
return {
name,
...stateData,
@ -52,7 +55,8 @@ export function specifyStates(
burgs: burgsNumber,
...stats,
neighbors,
relations
relations,
pole
};
});

View file

@ -0,0 +1,106 @@
import FlatQueue from "flatqueue";
import {DISTANCE_FIELD, ELEVATION, FOREST_BIOMES, MIN_LAND_HEIGHT} from "config/generation";
import {TIME} from "config/logging";
import {getInputNumber} from "utils/nodeUtils";
import {minmax} from "utils/numberUtils";
import {isCulture} from "utils/typeUtils";
const {LAND_COAST, LANDLOCKED, WATER_COAST} = DISTANCE_FIELD;
const {MOUNTAINS, HILLS} = ELEVATION;
// expand cultures across the map (Dijkstra-like algorithm)
export function expandCultures(
cultures: TCultures,
features: TPackFeatures,
cells: Pick<IPack["cells"], "c" | "area" | "h" | "t" | "f" | "r" | "fl" | "biome" | "pop">
) {
TIME && console.time("expandCultures");
const cultureIds = new Uint16Array(cells.h.length); // cell cultures
const queue = new FlatQueue<{cellId: number; cultureId: number}>();
cultures.filter(isCulture).forEach(culture => {
queue.push({cellId: culture.center, cultureId: culture.i}, 0);
});
const cellsNumberFactor = cells.h.length / 1.6;
const maxExpansionCost = cellsNumberFactor * getInputNumber("neutralInput"); // limit cost for culture growth
const cost: number[] = [];
while (queue.length) {
const priority = queue.peekValue()!;
const {cellId, cultureId} = queue.pop()!;
const {type, expansionism, center} = getCulture(cultureId);
const cultureBiome = cells.biome[center];
cells.c[cellId].forEach(neibCellId => {
const biomeCost = getBiomeCost(neibCellId, cultureBiome, type);
const heightCost = getHeightCost(neibCellId, cells.h[neibCellId], type);
const riverCost = getRiverCost(cells.r[neibCellId], neibCellId, type);
const typeCost = getTypeCost(cells.t[neibCellId], type);
const totalCost = priority + (biomeCost + heightCost + riverCost + typeCost) / expansionism;
if (totalCost > maxExpansionCost) return;
if (!cost[neibCellId] || totalCost < cost[neibCellId]) {
if (cells.pop[neibCellId] > 0) cultureIds[neibCellId] = cultureId; // assign culture to populated cell
cost[neibCellId] = totalCost;
queue.push({cellId: neibCellId, cultureId}, totalCost);
}
});
}
TIME && console.timeEnd("expandCultures");
return cultureIds;
function getCulture(cultureId: number) {
const culture = cultures[cultureId];
if (!isCulture(culture)) throw new Error("Wilderness cannot expand");
return culture;
}
function getBiomeCost(cellId: number, cultureBiome: number, type: TCultureType) {
const biome = cells.biome[cellId];
if (cultureBiome === biome) return 10; // tiny penalty for native biome
if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters
if (type === "Nomadic" && FOREST_BIOMES.includes(biome)) return biomesData.cost[biome] * 10; // forest biome penalty for nomads
return biomesData.cost[biome] * 2; // general non-native biome penalty
}
function getHeightCost(cellId: number, height: number, type: TCultureType) {
if (height < MIN_LAND_HEIGHT) {
const feature = features[cells.f[cellId]];
const area = cells.area[cellId];
if (type === "Lake" && feature && feature.type === "lake") return 10; // almost lake crossing penalty for Lake cultures
if (type === "Naval") return area * 2; // low sea or lake crossing penalty for Naval cultures
if (type === "Nomadic") return area * 50; // giant sea or lake crossing penalty for Nomads
return area * 6; // general sea or lake crossing penalty
}
if (type === "Highland") {
if (height >= MOUNTAINS) return 0; // no penalty for highlanders on highlands
if (height < HILLS) return 3000; // giant penalty for highlanders on lowlands
return 100; // penalty for highlanders on hills
}
if (height >= MOUNTAINS) return 200; // general mountains crossing penalty
if (height >= HILLS) return 30; // general hills crossing penalty
return 0;
}
function getRiverCost(riverId: number, cellId: number, type: TCultureType) {
if (type === "River") return riverId ? 0 : 100; // penalty for river cultures
if (!riverId) return 0; // no penalty for others if there is no river
return minmax(cells.fl[cellId] / 10, 20, 100); // river penalty from 20 to 100 based on flux
}
function getTypeCost(t: number, type: TCultureType) {
if (t === LAND_COAST) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline
if (t === LANDLOCKED) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads
if (t !== WATER_COAST) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals
return 0;
}
}

View file

@ -1,24 +1,15 @@
import * as d3 from "d3";
import FlatQueue from "flatqueue";
import {cultureSets, DEFAULT_SORT_STRING, TCultureSetName} from "config/cultureSets";
import {
DISTANCE_FIELD,
ELEVATION,
FOREST_BIOMES,
HUNTING_BIOMES,
MIN_LAND_HEIGHT,
NOMADIC_BIOMES
} from "config/generation";
import {DISTANCE_FIELD, ELEVATION, HUNTING_BIOMES, NOMADIC_BIOMES} from "config/generation";
import {ERROR, TIME, WARN} from "config/logging";
import {getColors} from "utils/colorUtils";
import {abbreviate} from "utils/languageUtils";
import {getInputNumber, getInputValue, getSelectedOption} from "utils/nodeUtils";
import {minmax, rn} from "utils/numberUtils";
import {rn} from "utils/numberUtils";
import {biased, P, rand} from "utils/probabilityUtils";
import {byId} from "utils/shorthands";
import {defaultNameBases} from "config/namebases";
import {isCulture} from "utils/typeUtils";
const {COA} = window;
@ -33,19 +24,17 @@ const cultureTypeBaseExpansionism: {[key in TCultureType]: number} = {
};
const {MOUNTAINS, HILLS} = ELEVATION;
const {LAND_COAST, LANDLOCKED, WATER_COAST} = DISTANCE_FIELD;
const {LAND_COAST, LANDLOCKED} = DISTANCE_FIELD;
export const generateCultures = function (
features: TPackFeatures,
cells: Pick<
IPack["cells"],
"p" | "i" | "g" | "t" | "h" | "haven" | "harbor" | "f" | "r" | "fl" | "s" | "pop" | "biome"
>,
temp: Int8Array
): TCultures {
type TCellsData = Pick<
IPack["cells"],
"p" | "i" | "g" | "t" | "h" | "haven" | "harbor" | "f" | "r" | "fl" | "s" | "pop" | "biome"
>;
export function generateCultures(features: TPackFeatures, cells: TCellsData, temp: Int8Array): TCultures {
TIME && console.time("generateCultures");
const wildlands: TWilderness = {name: "Wildlands", i: 0, base: 1, origins: [null], shield: "round"};
const wildlands: TWilderness = {i: 0, name: "Wildlands", base: 1, origins: [null], shield: "round"};
const populatedCellIds = cells.i.filter(cellId => cells.pop[cellId] > 0);
const maxSuitability = d3.max(cells.s)!;
@ -272,100 +261,4 @@ export const generateCultures = function (
ERROR && console.error(`Name base ${base} is not available, applying a fallback one`);
return base % nameBases.length;
}
};
// expand cultures across the map (Dijkstra-like algorithm)
export const expandCultures = function (
cultures: TCultures,
features: TPackFeatures,
cells: Pick<IPack["cells"], "c" | "area" | "h" | "t" | "f" | "r" | "fl" | "biome" | "pop">
) {
TIME && console.time("expandCultures");
const cultureIds = new Uint16Array(cells.h.length); // cell cultures
const queue = new FlatQueue<{cellId: number; cultureId: number}>();
cultures.filter(isCulture).forEach(culture => {
queue.push({cellId: culture.center, cultureId: culture.i}, 0);
});
const cellsNumberFactor = cells.h.length / 1.6;
const maxExpansionCost = cellsNumberFactor * getInputNumber("neutralInput"); // limit cost for culture growth
const cost: number[] = [];
while (queue.length) {
const priority = queue.peekValue()!;
const {cellId, cultureId} = queue.pop()!;
const {type, expansionism, center} = getCulture(cultureId);
const cultureBiome = cells.biome[center];
cells.c[cellId].forEach(neibCellId => {
const biomeCost = getBiomeCost(neibCellId, cultureBiome, type);
const heightCost = getHeightCost(neibCellId, cells.h[neibCellId], type);
const riverCost = getRiverCost(cells.r[neibCellId], neibCellId, type);
const typeCost = getTypeCost(cells.t[neibCellId], type);
const totalCost = priority + (biomeCost + heightCost + riverCost + typeCost) / expansionism;
if (totalCost > maxExpansionCost) return;
if (!cost[neibCellId] || totalCost < cost[neibCellId]) {
if (cells.pop[neibCellId] > 0) cultureIds[neibCellId] = cultureId; // assign culture to populated cell
cost[neibCellId] = totalCost;
queue.push({cellId: neibCellId, cultureId}, totalCost);
}
});
}
TIME && console.timeEnd("expandCultures");
return cultureIds;
function getCulture(cultureId: number) {
const culture = cultures[cultureId];
if (!isCulture(culture)) throw new Error("Wilderness cannot expand");
return culture;
}
function getBiomeCost(cellId: number, cultureBiome: number, type: TCultureType) {
const biome = cells.biome[cellId];
if (cultureBiome === biome) return 10; // tiny penalty for native biome
if (type === "Hunting") return biomesData.cost[biome] * 5; // non-native biome penalty for hunters
if (type === "Nomadic" && FOREST_BIOMES.includes(biome)) return biomesData.cost[biome] * 10; // forest biome penalty for nomads
return biomesData.cost[biome] * 2; // general non-native biome penalty
}
function getHeightCost(cellId: number, height: number, type: TCultureType) {
if (height < MIN_LAND_HEIGHT) {
const feature = features[cells.f[cellId]];
const area = cells.area[cellId];
if (type === "Lake" && feature && feature.type === "lake") return 10; // almost lake crossing penalty for Lake cultures
if (type === "Naval") return area * 2; // low sea or lake crossing penalty for Naval cultures
if (type === "Nomadic") return area * 50; // giant sea or lake crossing penalty for Nomads
return area * 6; // general sea or lake crossing penalty
}
if (type === "Highland") {
if (height >= MOUNTAINS) return 0; // no penalty for highlanders on highlands
if (height < HILLS) return 3000; // giant penalty for highlanders on lowlands
return 100; // penalty for highlanders on hills
}
if (height >= MOUNTAINS) return 200; // general mountains crossing penalty
if (height >= HILLS) return 30; // general hills crossing penalty
return 0;
}
function getRiverCost(riverId: number, cellId: number, type: TCultureType) {
if (type === "River") return riverId ? 0 : 100; // penalty for river cultures
if (!riverId) return 0; // no penalty for others if there is no river
return minmax(cells.fl[cellId] / 10, 20, 100); // river penalty from 20 to 100 based on flux
}
function getTypeCost(t: number, type: TCultureType) {
if (t === LAND_COAST) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline
if (t === LANDLOCKED) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads
if (t !== WATER_COAST) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals
return 0;
}
};
}

View file

@ -14,7 +14,7 @@ export interface ILakeClimateData extends IPackFeatureLake {
enteringFlux?: number;
}
export const getClimateData = function (
export function getClimateData(
lakes: IPackFeatureLake[],
heights: Float32Array,
drainableLakes: Dict<boolean>,
@ -44,13 +44,9 @@ export const getClimateData = function (
});
return lakeData;
};
}
export const mergeLakeData = function (
features: TPackFeatures,
lakeData: ILakeClimateData[],
rivers: Pick<IRiver, "i">[]
) {
export function mergeLakeData(features: TPackFeatures, lakeData: ILakeClimateData[], rivers: Pick<IRiver, "i">[]) {
const updatedFeatures = features.map(feature => {
if (!feature) return 0;
if (feature.type !== "lake") return feature;
@ -71,7 +67,7 @@ export const mergeLakeData = function (
});
return updatedFeatures as TPackFeatures;
};
}
function defineLakeGroup({
firstCell,

View file

@ -1,21 +1,15 @@
import * as d3 from "d3";
import {UINT16_MAX} from "config/constants";
import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation";
import {TIME} from "config/logging";
import {calculateVoronoi} from "scripts/generation/graph";
import {markupPackFeatures} from "scripts/generation/markup";
import {rankCells} from "scripts/generation/pack/rankCells";
import {createTypedArray} from "utils/arrayUtils";
import {pick} from "utils/functionUtils";
import {rn} from "utils/numberUtils";
import {generateCultures, expandCultures} from "./cultures";
import {generateRivers} from "./rivers";
import {generateBurgsAndStates} from "./burgsAndStates/generateBurgsAndStates";
import {generateRoutes} from "./generateRoutes";
import {expandCultures} from "./cultures/expandCultures";
import {generateCultures} from "./cultures/generateCultures";
import {generateProvinces} from "./provinces/generateProvinces";
import {generateReligions} from "./religions/generateReligions";
import {repackGrid} from "./repackGrid";
import {generateRivers} from "./rivers/generateRivers";
import {generateRoutes} from "./routes/generateRoutes";
const {LAND_COAST, WATER_COAST, DEEPER_WATER} = DISTANCE_FIELD;
const {Biomes} = window;
export function createPack(grid: IGrid): IPack {
@ -149,12 +143,17 @@ export function createPack(grid: IGrid): IPack {
}
});
// BurgsAndStates.generateProvinces();
// BurgsAndStates.defineBurgFeatures();
// renderLayer("states");
// renderLayer("borders");
// BurgsAndStates.drawStateLabels();
const {provinceIds, provinces} = generateProvinces(states, burgs, cultures, mergedFeatures, vertices, {
i: cells.i,
c: cells.c,
v: cells.v,
h: heights,
t: distanceField,
f: featureIds,
culture: cultureIds,
state: stateIds,
burg: burgIds
});
// Rivers.specify();
// const updatedFeatures = generateLakeNames();
@ -190,7 +189,7 @@ export function createPack(grid: IGrid): IPack {
state: stateIds,
route: cellRoutes,
religion: religionIds,
province: new Uint16Array(cells.i.length)
province: provinceIds
},
features: mergedFeatures,
rivers: rawRivers, // "name" | "basin" | "type"
@ -199,77 +198,9 @@ export function createPack(grid: IGrid): IPack {
burgs,
routes,
religions,
provinces,
events
};
return pack;
}
// repack grid cells: discart deep water cells, add land cells along the coast
function repackGrid(grid: IGrid) {
TIME && console.time("repackGrid");
const {cells: gridCells, points, features} = grid;
const newCells: {p: TPoints; g: number[]; h: number[]} = {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];
// exclude ocean points far from coast
if (height < MIN_LAND_HEIGHT && type !== WATER_COAST && type !== DEEPER_WATER) continue;
const feature = features[gridCells.f[i]];
const isLake = feature && feature.type === "lake";
// exclude non-coastal lake points
if (type === DEEPER_WATER && (i % 4 === 0 || isLake)) continue;
const [x, y] = points[i];
addNewPoint(i, x, y, height);
// add additional points for cells along coast
if (type === LAND_COAST || type === WATER_COAST) {
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: number, x: number, y: number, height: number) {
newCells.p.push([x, y]);
newCells.g.push(i);
newCells.h.push(height);
}
const {cells, vertices} = calculateVoronoi(newCells.p, grid.boundary);
function getCellArea(i: number) {
const polygon = cells.v[i].map(v => vertices.p[v]);
const area = Math.abs(d3.polygonArea(polygon));
return Math.min(area, UINT16_MAX);
}
const pack = {
vertices,
cells: {
...cells,
p: newCells.p,
g: createTypedArray({maxValue: grid.points.length, from: newCells.g}),
q: d3.quadtree(newCells.p.map(([x, y], i) => [x, y, i])) as unknown as Quadtree,
h: new Uint8Array(newCells.h),
area: createTypedArray({maxValue: UINT16_MAX, from: cells.i}).map(getCellArea)
}
};
TIME && console.timeEnd("repackGrid");
return pack;
}

View file

@ -0,0 +1,8 @@
export const provinceForms = {
Monarchy: {County: 22, Earldom: 6, Shire: 2, Landgrave: 2, Margrave: 2, Barony: 2, Captaincy: 1, Seneschalty: 1},
Republic: {Province: 6, Department: 2, Governorate: 2, District: 1, Canton: 1, Prefecture: 1},
Theocracy: {Parish: 3, Deanery: 1},
Union: {Province: 1, State: 1, Canton: 1, Republic: 1, County: 1, Council: 1},
Anarchy: {Council: 1, Commune: 1, Community: 1, Tribe: 1},
Wild: {Territory: 10, Land: 5, Region: 2, Tribe: 1, Clan: 1, Dependency: 1, Area: 1}
};

View file

@ -0,0 +1,86 @@
import FlatQueue from "flatqueue";
import {DISTANCE_FIELD, ELEVATION, MIN_LAND_HEIGHT} from "config/generation";
import {gauss} from "utils/probabilityUtils";
const {WATER_COAST} = DISTANCE_FIELD;
const {MOUNTAINS, HILLS, LOWLANDS} = ELEVATION;
export function expandProvinces(
percentage: number,
provinces: IProvince[],
cells: Pick<IPack["cells"], "i" | "c" | "h" | "t" | "state" | "burg">
) {
const provinceIds = new Uint16Array(cells.i.length);
const queue = new FlatQueue<{cellId: number; provinceId: number; stateId: number}>();
const cost: number[] = [];
const maxExpansionCost = percentage === 100 ? 1000 : gauss(20, 5, 5, 100) * percentage ** 0.5;
for (const {i: provinceId, center: cellId, state: stateId} of provinces) {
provinceIds[cellId] = provinceId;
cost[cellId] = 1;
queue.push({cellId, provinceId, stateId}, 0);
}
while (queue.length) {
const priority = queue.peekValue()!;
const {cellId, provinceId, stateId} = queue.pop()!;
cells.c[cellId].forEach(neibCellId => {
const isLand = cells.h[neibCellId] >= MIN_LAND_HEIGHT;
if (isLand && cells.state[neibCellId] !== stateId) return; // can expand only within state
const evevationCost = getElevationCost(cells.h[neibCellId], cells.t[neibCellId]);
const totalCost = priority + evevationCost;
if (totalCost > maxExpansionCost) return;
if (!cost[neibCellId] || totalCost < cost[neibCellId]) {
if (isLand) provinceIds[neibCellId] = provinceId; // assign province to cell
cost[neibCellId] = totalCost;
queue.push({cellId: neibCellId, provinceId, stateId}, totalCost);
}
});
}
return normalizeProvinces(provinceIds, cells.c, cells.state, cells.burg);
}
function getElevationCost(elevation: number, distance: number) {
if (elevation >= MOUNTAINS) return 100;
if (elevation >= HILLS) return 30;
if (elevation >= LOWLANDS) return 10;
if (elevation >= MIN_LAND_HEIGHT) return 5;
if (distance === WATER_COAST) return 100;
return 300; // deep water
}
function normalizeProvinces(
provinceIds: Uint16Array,
neibCells: number[][],
stateIds: Uint16Array,
burgIds: Uint16Array
) {
const normalizedIds = Uint16Array.from(provinceIds);
for (let cellId = 0; cellId < neibCells.length; cellId++) {
if (!stateIds[cellId]) continue; // skip water or neutral cells
if (burgIds[cellId]) continue; // do not overwrite burgs
const neibs = neibCells[cellId].filter(neib => stateIds[neib] >= stateIds[cellId]);
const adversaries = neibs.filter(neib => normalizedIds[neib] !== normalizedIds[cellId]);
if (adversaries.length < 2) continue;
const buddies = neibs.filter(neib => normalizedIds[neib] === normalizedIds[cellId]);
if (buddies.length > 2) continue;
// change cells's province
if (adversaries.length > buddies.length) normalizedIds[cellId] = normalizedIds[adversaries[0]];
}
return normalizedIds;
}

View file

@ -0,0 +1,69 @@
import {group} from "d3-array";
import {brighter, getMixedColor} from "utils/colorUtils";
import {gauss, P, rw} from "utils/probabilityUtils";
import {isBurg, isState} from "utils/typeUtils";
import {provinceForms} from "./config";
const {COA, Names} = window;
export function generateCoreProvinces(states: TStates, burgs: TBurgs, cultures: TCultures, percentage: number) {
const provinces = [] as IProvince[];
const validBurgs = burgs.filter(isBurg);
const burgsToStateMap = group(validBurgs, (burg: IBurg) => burg.state);
states.filter(isState).forEach(state => {
const stateBurgs = burgsToStateMap.get(state.i);
if (!stateBurgs || stateBurgs.length < 2) return; // at least 2 provinces are required
stateBurgs
.sort((a, b) => b.population * gauss(1, 0.2, 0.5, 1.5, 3) - a.population)
.sort((a, b) => b.capital - a.capital);
const provincesNumber = Math.max(Math.ceil((stateBurgs.length * percentage) / 100), 2);
const formsPool: Dict<number> = structuredClone(provinceForms[state.form]);
for (let i = 0; i < provincesNumber; i++) {
const {i: burg, cell: center, culture: cultureId, coa: burgEmblem, name: burgName, type} = stateBurgs[i];
const nameByBurg = P(0.5);
const name = generateName(nameByBurg, burgName, cultureId, cultures);
const formName = rw(formsPool);
formsPool[formName] += 10; // increase chance to get the same form again
const fullName = name + " " + formName;
const color = brighter(getMixedColor(state.color, 0.2), 0.3);
const coa = generateEmblem(nameByBurg, burgEmblem, type, cultures, cultureId, state);
provinces.push({i: provinces.length + 1, name, formName, center, burg, state: state.i, fullName, color, coa});
}
});
return provinces;
}
function generateName(nameByBurg: boolean, burgName: string, cultureId: number, cultures: TCultures) {
if (nameByBurg) return burgName;
const base = cultures[cultureId].base;
return Names.getState(Names.getBaseShort(base), base);
}
function generateEmblem(
nameByBurg: boolean,
burgEmblem: ICoa | "string",
type: TCultureType,
cultures: TCultures,
cultureId: number,
state: IState
) {
const kinship = nameByBurg ? 0.8 : 0.4;
const coa: ICoa = COA.generate(burgEmblem, kinship, null, type);
const cultureShield = cultures[cultureId].shield;
const stateShield = (state.coa as ICoa)?.shield;
coa.shield = COA.getShield(cultureShield, stateShield);
return coa;
}

View file

@ -0,0 +1,39 @@
import {TIME} from "config/logging";
import {getInputNumber} from "utils/nodeUtils";
import {expandProvinces} from "./expandProvinces";
import {generateCoreProvinces} from "./generateCoreProvinces";
import {generateWildProvinces} from "./generateWildProvinces";
import {specifyProvinces} from "./specifyProvinces";
export function generateProvinces(
states: TStates,
burgs: TBurgs,
cultures: TCultures,
features: TPackFeatures,
vertices: IGraphVertices,
cells: Pick<IPack["cells"], "i" | "c" | "v" | "h" | "t" | "f" | "culture" | "state" | "burg">
): {provinceIds: Uint16Array; provinces: TProvinces} {
TIME && console.time("generateProvinces");
const percentage = getInputNumber("provincesInput");
if (states.length < 2 || percentage === 0) return {provinceIds: new Uint16Array(cells.i.length), provinces: [0]};
const coreProvinces = generateCoreProvinces(states, burgs, cultures, percentage);
const provinceIds = expandProvinces(percentage, coreProvinces, cells);
const wildProvinces = generateWildProvinces({
states,
burgs,
cultures,
features,
coreProvinces,
provinceIds,
percentage,
cells
}); // mutates provinceIds
const provinces = specifyProvinces(provinceIds, coreProvinces, wildProvinces, vertices, cells.c, cells.v);
TIME && console.timeEnd("generateProvinces");
return {provinceIds, provinces};
}

View file

@ -0,0 +1,200 @@
import {group} from "d3-array";
import FlatQueue from "flatqueue";
import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation";
import {unique} from "utils/arrayUtils";
import {brighter, getMixedColor} from "utils/colorUtils";
import {gauss, P, ra, rw} from "utils/probabilityUtils";
import {isBurg, isState} from "utils/typeUtils";
import {provinceForms} from "./config";
const {COA, Names} = window;
// add "wild" provinces if some cells don't have a province assigned
export function generateWildProvinces({
states,
burgs,
cultures,
features,
coreProvinces,
provinceIds,
percentage,
cells
}: {
states: TStates;
burgs: TBurgs;
cultures: TCultures;
features: TPackFeatures;
coreProvinces: IProvince[];
provinceIds: Uint16Array;
percentage: number;
cells: Pick<IPack["cells"], "i" | "c" | "h" | "t" | "f" | "culture" | "state" | "burg">;
}) {
const noProvinceCells = Array.from(cells.i.filter(i => cells.state[i] && !provinceIds[i]));
const wildProvinces = [] as IProvince[];
const colonyNamesMap = createColonyNamesMap();
for (const state of states) {
if (!isState(state)) continue;
let noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === state.i);
while (noProvinceCellsInState.length) {
const provinceId = coreProvinces.length + wildProvinces.length + 1;
const burgCell = noProvinceCellsInState.find(i => cells.burg[i]);
const center = burgCell || noProvinceCellsInState[0];
const cultureId = cells.culture[center];
const burgId = burgCell ? cells.burg[burgCell] : 0;
const burg = burgs[burgId];
const provinceCells = expandWildProvince(center, provinceId, state.i); // mutates provinceIds
const formName = getProvinceForm(center, provinceCells, state.center);
const name = getProvinceName(state.i, formName, burg, cultureId);
const fullName = name + " " + formName;
const coa = generateEmblem(formName, state, burg, cultureId);
const color = brighter(getMixedColor(state.color, 0.2), 0.3);
wildProvinces.push({i: provinceId, name, formName, center, burg: burgId, state: state.i, fullName, color, coa});
// re-check
noProvinceCellsInState = noProvinceCells.filter(i => cells.state[i] === state.i && !provinceIds[i]);
}
}
return wildProvinces;
function createColonyNamesMap() {
const stateProvincesMap = group(coreProvinces, (province: IProvince) => province.state);
const colonyNamesMap = new Map<number, string[]>(
states.map(state => {
const stateProvinces = stateProvincesMap.get(state.i) || [];
const coreProvinceNames = stateProvinces.map(province => province.name);
const colonyNamePool = unique([state.name, ...coreProvinceNames].filter(name => name && !/new/i.test(name)));
return [state.i, colonyNamePool];
})
);
return colonyNamesMap;
}
function getColonyName(stateId: number) {
const namesPool = colonyNamesMap.get(stateId) || [];
if (namesPool.length < 1) return null;
const name = ra(namesPool);
colonyNamesMap.set(
stateId,
namesPool.filter(n => n !== name)
);
return `New ${name}`;
}
function getProvinceName(stateId: number, formName: string, burg: TNoBurg | IBurg, cultureId: number) {
const colonyName = formName === "Colony" && P(0.8) && getColonyName(stateId);
if (colonyName) return colonyName;
if (burg?.name && P(0.5)) return burg.name;
const base = cultures[cultureId].base;
return Names.getState(Names.getBaseShort(base), base);
}
function expandWildProvince(center: number, provinceId: number, stateId: number) {
const maxExpansionCost = percentage === 100 ? 1000 : gauss(20, 5, 5, 100) * percentage ** 0.5;
const provinceCells = [center];
provinceIds[center] = provinceId;
const queue = new FlatQueue<number>();
const cost: number[] = [];
cost[center] = 1;
queue.push(center, 0);
while (queue.length) {
const priority = queue.peekValue()!;
const next = queue.pop()!;
cells.c[next].forEach(neibCellId => {
if (provinceIds[neibCellId]) return;
if (cells.state[neibCellId] !== stateId) return;
const isLand = cells.h[neibCellId] >= MIN_LAND_HEIGHT;
const cellCost = isLand ? 3 : cells.t[neibCellId] === DISTANCE_FIELD.WATER_COAST ? 10 : 30;
const totalCost = priority + cellCost;
if (totalCost > maxExpansionCost) return;
if (!cost[neibCellId] || totalCost < cost[neibCellId]) {
if (isLand && cells.state[neibCellId] === stateId) {
// assign province to a cell
provinceCells.push(neibCellId);
provinceIds[neibCellId] = provinceId;
}
cost[neibCellId] = totalCost;
queue.push(neibCellId, totalCost);
}
});
}
return provinceCells;
}
function getProvinceForm(center: number, provinceCells: number[], stateCenter: number) {
const feature = features[cells.f[center]];
if (feature === 0) throw new Error("Feature is not defined");
const provinceFeatures = unique(provinceCells.map(i => cells.f[i]));
const isWholeIsle = provinceCells.length === feature.cells && provinceFeatures.length === 1;
if (isWholeIsle) return "Island";
const isIsleGroup = provinceFeatures.every(featureId => (features[featureId] as TPackFeature)?.group === "isle");
if (isIsleGroup) return "Islands";
const isColony = P(0.5) && !isConnected(stateCenter, center);
if (isColony) return "Colony";
return rw(provinceForms["Wild"]);
// check if two cells are connected by land withing same state
function isConnected(from: number, to: number) {
if (cells.f[from] !== cells.f[to]) return false; // on different islands
const queue = [from];
const checked: Dict<boolean> = {[from]: true};
const stateId = cells.state[from];
while (queue.length) {
const current = queue.pop()!;
if (current === to) return true;
for (const neibId of cells.c[current]) {
if (checked[neibId] || cells.state[neibId] !== stateId) continue;
queue.push(neibId);
checked[neibId] = true;
}
}
return false;
}
}
function generateEmblem(formName: string, state: IState, burg: TNoBurg | IBurg, cultureId: number) {
const dominion = P(getDominionChance(formName));
const kinship = dominion ? 0 : 0.4;
const coaType = isBurg(burg) ? burg.type : "Generic";
const coa = COA.generate(state.coa, kinship, dominion, coaType);
const cultureShield = cultures[cultureId].shield;
const stateShield = (state.coa as ICoa)?.shield;
coa.shield = COA.getShield(cultureShield, stateShield);
return coa;
}
function getDominionChance(formName: string) {
if (formName === "Colony") return 0.95;
if (formName === "Island") return 0.7;
if (formName === "Islands") return 0.7;
return 0.3;
}
}

View file

@ -0,0 +1,20 @@
import {getPolesOfInaccessibility} from "scripts/getPolesOfInaccessibility";
export function specifyProvinces(
provinceIds: Uint16Array,
coreProvinces: IProvince[],
wildProvinces: IProvince[],
vertices: IGraphVertices,
cellNeighbors: number[][],
cellVertices: number[][]
): TProvinces {
const getType = (cellId: number) => provinceIds[cellId];
const poles = getPolesOfInaccessibility({vertices, getType, cellNeighbors, cellVertices});
const provinces = [...coreProvinces, ...wildProvinces].map(province => {
const pole = poles[province.i];
return {...province, pole};
});
return [0, ...provinces];
}

View file

@ -0,0 +1,79 @@
import * as d3 from "d3";
import {UINT16_MAX} from "config/constants";
import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation";
import {TIME} from "config/logging";
import {createTypedArray} from "utils/arrayUtils";
import {rn} from "utils/numberUtils";
import {calculateVoronoi} from "../graph";
const {LAND_COAST, WATER_COAST, DEEPER_WATER} = DISTANCE_FIELD;
// repack grid cells: discart deep water cells, add land cells along the coast
export function repackGrid(grid: IGrid) {
TIME && console.time("repackGrid");
const {cells: gridCells, points, features} = grid;
const newCells: {p: TPoints; g: number[]; h: number[]} = {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];
// exclude ocean points far from coast
if (height < MIN_LAND_HEIGHT && type !== WATER_COAST && type !== DEEPER_WATER) continue;
const feature = features[gridCells.f[i]];
const isLake = feature && feature.type === "lake";
// exclude non-coastal lake points
if (type === DEEPER_WATER && (i % 4 === 0 || isLake)) continue;
const [x, y] = points[i];
addNewPoint(i, x, y, height);
// add additional points for cells along coast
if (type === LAND_COAST || type === WATER_COAST) {
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: number, x: number, y: number, height: number) {
newCells.p.push([x, y]);
newCells.g.push(i);
newCells.h.push(height);
}
const {cells, vertices} = calculateVoronoi(newCells.p, grid.boundary);
function getCellArea(i: number) {
const polygon = cells.v[i].map(v => vertices.p[v]);
const area = Math.abs(d3.polygonArea(polygon));
return Math.min(area, UINT16_MAX);
}
const pack = {
vertices,
cells: {
...cells,
p: newCells.p,
g: createTypedArray({maxValue: grid.points.length, from: newCells.g}),
q: d3.quadtree(newCells.p.map(([x, y], i) => [x, y, i])) as unknown as Quadtree,
h: new Uint8Array(newCells.h),
area: createTypedArray({maxValue: UINT16_MAX, from: cells.i}).map(getCellArea)
}
};
TIME && console.timeEnd("repackGrid");
return pack;
}

View file

@ -1,13 +1,13 @@
import * as d3 from "d3";
import {INFO, TIME, WARN} from "config/logging";
import {TIME} from "config/logging";
import {rn} from "utils/numberUtils";
import {aleaPRNG} from "scripts/aleaPRNG";
import {DISTANCE_FIELD, MAX_HEIGHT, MIN_LAND_HEIGHT} from "config/generation";
import {getInputNumber} from "utils/nodeUtils";
import {DISTANCE_FIELD, MIN_LAND_HEIGHT} from "config/generation";
import {pick} from "utils/functionUtils";
import {byId} from "utils/shorthands";
import {mergeLakeData, getClimateData, ILakeClimateData} from "./lakes";
import {mergeLakeData, getClimateData, ILakeClimateData} from "../lakes/lakes";
import {resolveDepressions} from "./resolveDepressions";
const {Rivers} = window;
const {LAND_COAST} = DISTANCE_FIELD;
@ -276,155 +276,10 @@ export function generateRivers(
}
// add distance to water value to land cells to make map less depressed
const applyDistanceField = ({h, c, t}: Pick<IPack["cells"], "h" | "c" | "t">) => {
function applyDistanceField({h, c, t}: Pick<IPack["cells"], "h" | "c" | "t">) {
return new Float32Array(h.length).map((_, index) => {
if (h[index] < MIN_LAND_HEIGHT || t[index] < LAND_COAST) return h[index];
const mean = d3.mean(c[index].map(c => t[c])) || 0;
return h[index] + t[index] / 100 + mean / 10000;
});
};
// depression filling algorithm (for a correct water flux modeling)
const resolveDepressions = function (
cells: Pick<IPack["cells"], "i" | "c" | "b" | "f">,
features: TPackFeatures,
initialCellHeights: Float32Array
): [Float32Array, Dict<boolean>] {
TIME && console.time("resolveDepressions");
const MAX_INTERATIONS = getInputNumber("resolveDepressionsStepsOutput");
const checkLakeMaxIteration = MAX_INTERATIONS * 0.85;
const elevateLakeMaxIteration = MAX_INTERATIONS * 0.75;
const LAND_ELEVATION_INCREMENT = 0.1;
const LAKE_ELEVATION_INCREMENT = 0.2;
const lakes = features.filter(feature => feature && feature.type === "lake") as IPackFeatureLake[];
lakes.sort((a, b) => a.height - b.height); // lowest lakes go first
const getHeight = (i: number) => currentLakeHeights[cells.f[i]] || currentCellHeights[i];
const getMinHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(getHeight));
const getMinLandHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(i => currentCellHeights[i]));
const landCells = cells.i.filter(i => initialCellHeights[i] >= MIN_LAND_HEIGHT && !cells.b[i]);
landCells.sort((a, b) => initialCellHeights[a] - initialCellHeights[b]); // lowest cells go first
const currentCellHeights = Float32Array.from(initialCellHeights);
const currentLakeHeights = Object.fromEntries(lakes.map(({i, height}) => [i, height]));
const currentDrainableLakes = checkLakesDrainability();
const depressions: number[] = [];
let bestDepressions = Infinity;
let bestCellHeights: typeof currentCellHeights | null = null;
let bestDrainableLakes: typeof currentDrainableLakes | null = null;
for (let iteration = 0; depressions.at(-1) !== 0 && iteration < MAX_INTERATIONS; iteration++) {
let depressionsLeft = 0;
// elevate potentially drainable lakes
if (iteration < checkLakeMaxIteration) {
for (const lake of lakes) {
if (currentDrainableLakes[lake.i] !== true) continue;
const minShoreHeight = getMinLandHeight(lake.shoreline);
if (minShoreHeight >= MAX_HEIGHT || currentLakeHeights[lake.i] > minShoreHeight) continue;
if (iteration > elevateLakeMaxIteration) {
// reset heights
for (const shoreCellId of lake.shoreline) {
currentCellHeights[shoreCellId] = initialCellHeights[shoreCellId];
}
currentLakeHeights[lake.i] = lake.height;
currentDrainableLakes[lake.i] = false;
continue;
}
currentLakeHeights[lake.i] = minShoreHeight + LAKE_ELEVATION_INCREMENT;
depressionsLeft++;
}
}
for (const cellId of landCells) {
const minHeight = getMinHeight(cells.c[cellId]);
if (minHeight >= MAX_HEIGHT || currentCellHeights[cellId] > minHeight) continue;
currentCellHeights[cellId] = minHeight + LAND_ELEVATION_INCREMENT;
depressionsLeft++;
}
depressions.push(depressionsLeft);
if (depressionsLeft < bestDepressions) {
bestDepressions = depressionsLeft;
bestCellHeights = Float32Array.from(currentCellHeights);
bestDrainableLakes = structuredClone(currentDrainableLakes);
}
}
TIME && console.timeEnd("resolveDepressions");
const depressionsLeft = depressions.at(-1);
if (depressionsLeft) {
if (bestCellHeights && bestDrainableLakes) {
WARN &&
console.warn(`Cannot resolve all depressions. Depressions: ${depressions[0]}. Best result: ${bestDepressions}`);
return [bestCellHeights, bestDrainableLakes];
}
WARN && console.warn(`Cannot resolve depressions. Depressions: ${depressionsLeft}`);
return [initialCellHeights, {}];
}
INFO && console.info(`ⓘ resolved all ${depressions[0]} depressions in ${depressions.length} iterations`);
return [currentCellHeights, currentDrainableLakes];
// define lakes that potentially can be open (drained into another water body)
function checkLakesDrainability() {
const canBeDrained: Dict<boolean> = {}; // all false by default
const ELEVATION_LIMIT = getInputNumber("lakeElevationLimitOutput");
const drainAllLakes = ELEVATION_LIMIT === MAX_HEIGHT - MIN_LAND_HEIGHT;
for (const lake of lakes) {
if (drainAllLakes) {
canBeDrained[lake.i] = true;
continue;
}
canBeDrained[lake.i] = false;
const minShoreHeight = getMinHeight(lake.shoreline);
const minHeightShoreCell =
lake.shoreline.find(cellId => initialCellHeights[cellId] === minShoreHeight) || lake.shoreline[0];
const queue = [minHeightShoreCell];
const checked = [];
checked[minHeightShoreCell] = true;
const breakableHeight = lake.height + ELEVATION_LIMIT;
loopCellsAroundLake: while (queue.length) {
const cellId = queue.pop()!;
for (const neibCellId of cells.c[cellId]) {
if (checked[neibCellId]) continue;
if (initialCellHeights[neibCellId] >= breakableHeight) continue;
if (initialCellHeights[neibCellId] < MIN_LAND_HEIGHT) {
const waterFeatureMet = features[cells.f[neibCellId]];
const isOceanMet = waterFeatureMet && waterFeatureMet.type === "ocean";
const isLakeMet = waterFeatureMet && waterFeatureMet.type === "lake";
if (isOceanMet || (isLakeMet && lake.height > waterFeatureMet.height)) {
canBeDrained[lake.i] = true;
break loopCellsAroundLake;
}
}
checked[neibCellId] = true;
queue.push(neibCellId);
}
}
}
return canBeDrained;
}
};
}

View file

@ -0,0 +1,148 @@
import {MIN_LAND_HEIGHT, MAX_HEIGHT} from "config/generation";
import {TIME, WARN, INFO} from "config/logging";
import {getInputNumber} from "utils/nodeUtils";
// depression filling algorithm (for a correct water flux modeling)
export function resolveDepressions(
cells: Pick<IPack["cells"], "i" | "c" | "b" | "f">,
features: TPackFeatures,
initialCellHeights: Float32Array
): [Float32Array, Dict<boolean>] {
TIME && console.time("resolveDepressions");
const MAX_INTERATIONS = getInputNumber("resolveDepressionsStepsOutput");
const checkLakeMaxIteration = MAX_INTERATIONS * 0.85;
const elevateLakeMaxIteration = MAX_INTERATIONS * 0.75;
const LAND_ELEVATION_INCREMENT = 0.1;
const LAKE_ELEVATION_INCREMENT = 0.2;
const lakes = features.filter(feature => feature && feature.type === "lake") as IPackFeatureLake[];
lakes.sort((a, b) => a.height - b.height); // lowest lakes go first
const getHeight = (i: number) => currentLakeHeights[cells.f[i]] || currentCellHeights[i];
const getMinHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(getHeight));
const getMinLandHeight = (cellsIds: number[]) => Math.min(...cellsIds.map(i => currentCellHeights[i]));
const landCells = cells.i.filter(i => initialCellHeights[i] >= MIN_LAND_HEIGHT && !cells.b[i]);
landCells.sort((a, b) => initialCellHeights[a] - initialCellHeights[b]); // lowest cells go first
const currentCellHeights = Float32Array.from(initialCellHeights);
const currentLakeHeights = Object.fromEntries(lakes.map(({i, height}) => [i, height]));
const currentDrainableLakes = checkLakesDrainability();
const depressions: number[] = [];
let bestDepressions = Infinity;
let bestCellHeights: typeof currentCellHeights | null = null;
let bestDrainableLakes: typeof currentDrainableLakes | null = null;
for (let iteration = 0; depressions.at(-1) !== 0 && iteration < MAX_INTERATIONS; iteration++) {
let depressionsLeft = 0;
// elevate potentially drainable lakes
if (iteration < checkLakeMaxIteration) {
for (const lake of lakes) {
if (currentDrainableLakes[lake.i] !== true) continue;
const minShoreHeight = getMinLandHeight(lake.shoreline);
if (minShoreHeight >= MAX_HEIGHT || currentLakeHeights[lake.i] > minShoreHeight) continue;
if (iteration > elevateLakeMaxIteration) {
// reset heights
for (const shoreCellId of lake.shoreline) {
currentCellHeights[shoreCellId] = initialCellHeights[shoreCellId];
}
currentLakeHeights[lake.i] = lake.height;
currentDrainableLakes[lake.i] = false;
continue;
}
currentLakeHeights[lake.i] = minShoreHeight + LAKE_ELEVATION_INCREMENT;
depressionsLeft++;
}
}
for (const cellId of landCells) {
const minHeight = getMinHeight(cells.c[cellId]);
if (minHeight >= MAX_HEIGHT || currentCellHeights[cellId] > minHeight) continue;
currentCellHeights[cellId] = minHeight + LAND_ELEVATION_INCREMENT;
depressionsLeft++;
}
depressions.push(depressionsLeft);
if (depressionsLeft < bestDepressions) {
bestDepressions = depressionsLeft;
bestCellHeights = Float32Array.from(currentCellHeights);
bestDrainableLakes = structuredClone(currentDrainableLakes);
}
}
TIME && console.timeEnd("resolveDepressions");
const depressionsLeft = depressions.at(-1);
if (depressionsLeft) {
if (bestCellHeights && bestDrainableLakes) {
WARN &&
console.warn(`Cannot resolve all depressions. Depressions: ${depressions[0]}. Best result: ${bestDepressions}`);
return [bestCellHeights, bestDrainableLakes];
}
WARN && console.warn(`Cannot resolve depressions. Depressions: ${depressionsLeft}`);
return [initialCellHeights, {}];
}
INFO && console.info(`ⓘ resolved all ${depressions[0]} depressions in ${depressions.length} iterations`);
return [currentCellHeights, currentDrainableLakes];
// define lakes that potentially can be open (drained into another water body)
function checkLakesDrainability() {
const canBeDrained: Dict<boolean> = {}; // all false by default
const ELEVATION_LIMIT = getInputNumber("lakeElevationLimitOutput");
const drainAllLakes = ELEVATION_LIMIT === MAX_HEIGHT - MIN_LAND_HEIGHT;
for (const lake of lakes) {
if (drainAllLakes) {
canBeDrained[lake.i] = true;
continue;
}
canBeDrained[lake.i] = false;
const minShoreHeight = getMinHeight(lake.shoreline);
const minHeightShoreCell =
lake.shoreline.find(cellId => initialCellHeights[cellId] === minShoreHeight) || lake.shoreline[0];
const queue = [minHeightShoreCell];
const checked = [];
checked[minHeightShoreCell] = true;
const breakableHeight = lake.height + ELEVATION_LIMIT;
loopCellsAroundLake: while (queue.length) {
const cellId = queue.pop()!;
for (const neibCellId of cells.c[cellId]) {
if (checked[neibCellId]) continue;
if (initialCellHeights[neibCellId] >= breakableHeight) continue;
if (initialCellHeights[neibCellId] < MIN_LAND_HEIGHT) {
const waterFeatureMet = features[cells.f[neibCellId]];
const isOceanMet = waterFeatureMet && waterFeatureMet.type === "ocean";
const isLakeMet = waterFeatureMet && waterFeatureMet.type === "lake";
if (isOceanMet || (isLakeMet && lake.height > waterFeatureMet.height)) {
canBeDrained[lake.i] = true;
break loopCellsAroundLake;
}
}
checked[neibCellId] = true;
queue.push(neibCellId);
}
}
}
return canBeDrained;
}
}

View file

@ -1,10 +1,10 @@
import Delaunator from "delaunator";
import FlatQueue from "flatqueue";
import {TIME} from "config/logging";
import {ELEVATION, MIN_LAND_HEIGHT, ROUTES} from "config/generation";
import {dist2} from "utils/functionUtils";
import {isBurg} from "utils/typeUtils";
import {calculateUrquhartEdges} from "./urquhart";
type TCellsData = Pick<IPack["cells"], "c" | "p" | "g" | "h" | "t" | "biome" | "burg">;
@ -292,44 +292,3 @@ function getRouteSegments(pathCells: number[], connections: Map<string, boolean>
return segments;
}
// Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation
// this gives us an aproximation of a desired road network, i.e. connections between burgs
// code from https://observablehq.com/@mbostock/urquhart-graph
function calculateUrquhartEdges(points: TPoints) {
const score = (p0: number, p1: number) => dist2(points[p0], points[p1]);
const {halfedges, triangles} = Delaunator.from(points);
const n = triangles.length;
const removed = new Uint8Array(n);
const edges = [];
for (let e = 0; e < n; e += 3) {
const p0 = triangles[e],
p1 = triangles[e + 1],
p2 = triangles[e + 2];
const p01 = score(p0, p1),
p12 = score(p1, p2),
p20 = score(p2, p0);
removed[
p20 > p01 && p20 > p12
? Math.max(e + 2, halfedges[e + 2])
: p12 > p01 && p12 > p20
? Math.max(e + 1, halfedges[e + 1])
: Math.max(e, halfedges[e])
] = 1;
}
for (let e = 0; e < n; ++e) {
if (e > halfedges[e] && !removed[e]) {
const t0 = triangles[e];
const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1];
edges.push([t0, t1]);
}
}
return edges;
}

View file

@ -0,0 +1,44 @@
import Delaunator from "delaunator";
import {dist2} from "utils/functionUtils";
// Urquhart graph is obtained by removing the longest edge from each triangle in the Delaunay triangulation
// this gives us an aproximation of a desired road network, i.e. connections between burgs
// code from https://observablehq.com/@mbostock/urquhart-graph
export function calculateUrquhartEdges(points: TPoints) {
const score = (p0: number, p1: number) => dist2(points[p0], points[p1]);
const {halfedges, triangles} = Delaunator.from(points);
const n = triangles.length;
const removed = new Uint8Array(n);
const edges = [];
for (let e = 0; e < n; e += 3) {
const p0 = triangles[e],
p1 = triangles[e + 1],
p2 = triangles[e + 2];
const p01 = score(p0, p1),
p12 = score(p1, p2),
p20 = score(p2, p0);
removed[
p20 > p01 && p20 > p12
? Math.max(e + 2, halfedges[e + 2])
: p12 > p01 && p12 > p20
? Math.max(e + 1, halfedges[e + 1])
: Math.max(e, halfedges[e])
] = 1;
}
for (let e = 0; e < n; ++e) {
if (e > halfedges[e] && !removed[e]) {
const t0 = triangles[e];
const t1 = triangles[e % 3 === 2 ? e - 2 : e + 1];
edges.push([t0, t1]);
}
}
return edges;
}

View file

@ -0,0 +1,66 @@
import polylabel from "polylabel";
import {TIME} from "config/logging";
import {connectVertices} from "./connectVertices";
import {rn} from "utils/numberUtils";
interface IGetPolesProps {
vertices: IGraphVertices;
cellNeighbors: number[][];
cellVertices: number[][];
getType: (cellId: number) => number;
}
export function getPolesOfInaccessibility(props: IGetPolesProps) {
TIME && console.time("getPolesOfInaccessibility");
const multiPolygons = getMultiPolygons(props);
const sortByLength = (a: unknown[], b: unknown[]) => b.length - a.length;
const poles: Dict<TPoint> = Object.fromEntries(
Object.entries(multiPolygons).map(([id, multiPolygon]) => {
const [x, y] = polylabel(multiPolygon.sort(sortByLength), 20);
return [id, [rn(x), rn(y)]];
})
);
TIME && console.timeEnd("getPolesOfInaccessibility");
return poles;
}
function getMultiPolygons({vertices, getType, cellNeighbors, cellVertices}: IGetPolesProps) {
const multiPolygons: Dict<number[][][]> = {};
const checkedCells = new Uint8Array(cellNeighbors.length);
const addToChecked = (cellId: number) => {
checkedCells[cellId] = 1;
};
const isChecked = (cellId: number) => checkedCells[cellId] === 1;
for (let cellId = 0; cellId < cellNeighbors.length; cellId++) {
if (isChecked(cellId) || getType(cellId) === 0) continue;
addToChecked(cellId);
const type = getType(cellId);
const ofSameType = (cellId: number) => getType(cellId) === type;
const ofDifferentType = (cellId: number) => getType(cellId) !== type;
const onborderCell = cellNeighbors[cellId].find(ofDifferentType);
if (onborderCell === undefined) continue;
const startingVertex = cellVertices[cellId].find(v => vertices.c[v].some(ofDifferentType));
if (startingVertex === undefined) throw new Error(`Starting vertex for cell ${cellId} is not found`);
const vertexChain = connectVertices({vertices, startingVertex, ofSameType, addToChecked, closeRing: true});
if (vertexChain.length < 3) continue;
addPolygon(type, vertexChain);
}
return multiPolygons;
function addPolygon(id: number, vertexChain: number[]) {
if (!multiPolygons[id]) multiPolygons[id] = [];
const polygon = vertexChain.map(vertex => vertices.p[vertex]);
multiPolygons[id].push(polygon);
}
}

View file

@ -3,6 +3,7 @@ interface IBurg {
name: string;
feature: number;
state: number;
culture: number;
cell: number;
x: number;
y: number;
@ -11,8 +12,12 @@ interface IBurg {
coa: ICoa | "string";
capital: Logical; // 1 - capital, 0 - burg
port: number; // port feature id, 0 - not a port
shanty?: number;
MFCG?: string | number;
citadel: Logical;
plaza: Logical;
walls: Logical;
shanty: Logical;
temple: Logical;
MFCG?: string | number; // MFCG link of seed
removed?: boolean;
}

View file

@ -27,8 +27,8 @@ interface IPackCells {
state: Uint16Array;
culture: Uint16Array;
religion: Uint16Array;
province: UintArray;
burg: UintArray;
province: Uint16Array;
burg: Uint16Array;
haven: UintArray;
harbor: UintArray;
route: Uint8Array; // [0, 1, 2, 3], see ROUTES enum, defined by generateRoutes()

View file

@ -1,8 +1,17 @@
interface IProvince {
i: number;
name: string;
burg: number;
formName: string;
fullName: string;
color: Hex | CssUrls;
state: number;
center: number;
pole: TPoint;
coa: ICoa | string;
removed?: boolean;
}
type TProvinces = IProvince[];
type TNoProvince = 0;
type TProvinces = [TNoProvince, ...IProvince[]];

View file

@ -7,11 +7,11 @@ interface IState {
type: TCultureType;
culture: number;
expansionism: number;
form: string;
form: TStateForm;
formName: string;
fullName: string;
pole: TPoint;
coa: ICoa | string;
// pole: TPoint ?
area: number;
cells: number;
burgs: number;
@ -38,6 +38,8 @@ interface ICoa {
t1: string;
}
type TStateForm = "Monarchy" | "Republic" | "Theocracy" | "Union" | "Anarchy";
type TRelation =
| "Ally"
| "Friendly"

View file

@ -31,6 +31,19 @@ export function drawLine([x1, y1]: TPoint, [x2, y2]: TPoint, {stroke = "#444", s
.attr("stroke-width", strokeWidth);
}
export function drawPolyline(points: TPoints, {fill = "none", stroke = "#444", strokeWidth = 0.2} = {}) {
debug
.append("polyline")
.attr("points", points.join(" "))
.attr("fill", fill)
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth);
}
export function drawPath(d: string, {fill = "none", stroke = "#444", strokeWidth = 0.2} = {}) {
debug.append("path").attr("d", d).attr("fill", fill).attr("stroke", stroke).attr("stroke-width", strokeWidth);
}
export function drawArrow([x1, y1]: TPoint, [x2, y2]: TPoint, {width = 1, color = "#444"} = {}) {
const normal = getNormal([x1, y1], [x2, y2]);
const [xMid, yMid] = [(x1 + x2) / 2, (y1 + y2) / 2];

View file

@ -14,3 +14,6 @@ export const isBurg = (burg: TNoBurg | IBurg): burg is IBurg => burg.i !== 0 &&
export const isReligion = (religion: TNoReligion | IReligion): religion is IReligion =>
religion.i !== 0 && !(religion as IReligion).removed;
export const isProvince = (province: TNoProvince | IProvince): province is IProvince =>
province !== 0 && !(province as IProvince).removed;

View file

@ -155,6 +155,11 @@
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-1.2.9.tgz#c7dc78992cd8ca5c850243a265fd257ea56df1fa"
integrity sha512-E/7RgPr2ylT5dWG0CswMi9NpFcjIEDqLcUSBgNHe/EMahfqYaTx4zhcggG3khqoEB/leY4Vl6nTSbwLUPjXceA==
"@types/d3-array@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.3.tgz#87d990bf504d14ad6b16766979d04e943c046dac"
integrity sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==
"@types/d3-axis@^1":
version "1.0.16"
resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-1.0.16.tgz#93d7a28795c2f8b0e2fd550fcc4d29b7f174e693"
@ -738,6 +743,13 @@ d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0:
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
d3-array@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14"
integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==
dependencies:
internmap "1 - 2"
d3-axis@1:
version "1.0.12"
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9"
@ -1469,6 +1481,11 @@ inherits@2, inherits@^2.0.3, inherits@~2.0.3:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
"internmap@1 - 2":
version "2.0.3"
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
is-builtin-module@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.1.0.tgz#6fdb24313b1c03b75f8b9711c0feb8c30b903b00"