@@ -2069,6 +2089,9 @@
+
@@ -2091,6 +2114,7 @@
Burgs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Upload:
+ raster
+ vector
+
+
+
+
+
+
+
@@ -7896,6 +7992,215 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -8081,6 +8386,10 @@
+
+
+
+
@@ -8129,6 +8438,7 @@
+
diff --git a/main.js b/main.js
index a38d96c11..b4d5e5629 100644
--- a/main.js
+++ b/main.js
@@ -77,6 +77,7 @@ let ice = viewbox.append("g").attr("id", "ice");
let prec = viewbox.append("g").attr("id", "prec").style("display", "none");
let population = viewbox.append("g").attr("id", "population");
let emblems = viewbox.append("g").attr("id", "emblems").style("display", "none");
+let goods = viewbox.append("g").attr("id", "goods");
let labels = viewbox.append("g").attr("id", "labels");
let icons = viewbox.append("g").attr("id", "icons");
let burgIcons = icons.append("g").attr("id", "burgIcons");
@@ -647,6 +648,8 @@ async function generate(options) {
Rivers.generate();
Biomes.define();
+ Resources.generate();
+
rankCells();
Cultures.generate();
Cultures.expand();
@@ -657,6 +660,14 @@ async function generate(options) {
Provinces.generate();
Provinces.getPoles();
BurgsAndStates.defineBurgFeatures();
+ BurgsAndStates.defineTaxes();
+
+ Production.collectResources();
+
+ Trade.defineCenters();
+ Trade.calculateDistances();
+ Trade.exportGoods();
+ Trade.importGoods();
Rivers.specify();
Features.specify();
@@ -1172,6 +1183,7 @@ function rankCells() {
const flMean = d3.median(cells.fl.filter(f => f)) || 0;
const flMax = d3.max(cells.fl) + d3.max(cells.conf); // to normalize flux
const areaMean = d3.mean(cells.area); // to adjust population by cell area
+ const getResValue = (i) => (cells.resource[i] ? Resources.get(cells.resource[i])?.value : 0); // get bonus resource scope
for (const i of cells.i) {
if (cells.h[i] < 20) continue; // no population in water
@@ -1196,7 +1208,12 @@ function rankCells() {
}
}
- cells.s[i] = s / 5; // general population rate
+ // add bonus for resource around
+ const cellRes = getResValue(i);
+ const neibRes = d3.mean(cells.c[i].map((c) => getResValue(c)));
+ const resBonus = (cellRes ? cellRes + 10 : 0) + neibRes;
+
+ cells.s[i] = s / 5 + resBonus; // general population rate
// cell rural population is suitability adjusted by cell area
cells.pop[i] = cells.s[i] > 0 ? (cells.s[i] * cells.area[i]) / areaMean : 0;
}
diff --git a/modules/burgs-and-states.js b/modules/burgs-and-states.js
index 8e3879c1e..80fdd47ff 100644
--- a/modules/burgs-and-states.js
+++ b/modules/burgs-and-states.js
@@ -865,6 +865,29 @@ window.BurgsAndStates = (() => {
return adjName ? `${getAdjective(state.name)} ${state.formName}` : `${state.formName} of ${state.name}`;
};
+ const defineTaxes = () => {
+ const {states} = pack;
+ const maxTaxPerForm = {
+ Monarchy: 0.3,
+ Republic: 0.1,
+ Union: 0.2,
+ Thearchy: 0.3,
+ Anarchy: 0
+ };
+
+ for (const state of states) {
+ const {i, removed, form} = state;
+ if (removed) continue;
+ if (!i) {
+ state.salesTax = 0;
+ continue;
+ }
+
+ const maxTax = maxTaxPerForm[form] || 0;
+ state.salesTax = maxTax ? rn(Math.random() * maxTax, 2) : 0;
+ }
+ };
+
return {
generate,
expandStates,
@@ -881,6 +904,7 @@ window.BurgsAndStates = (() => {
defineStateForms,
getFullName,
updateCultures,
- getCloseToEdgePoint
+ getCloseToEdgePoint,
+ defineTaxes
};
-})();
+})();
\ No newline at end of file
diff --git a/modules/io/load.js b/modules/io/load.js
index 8e05a7984..56b4f8136 100644
--- a/modules/io/load.js
+++ b/modules/io/load.js
@@ -343,6 +343,7 @@ async function parseLoadedData(data, mapVersion) {
coastline = viewbox.select("#coastline");
prec = viewbox.select("#prec");
population = viewbox.select("#population");
+ resources = viewbox.select("#goods")
emblems = viewbox.select("#emblems");
labels = viewbox.select("#labels");
icons = viewbox.select("#icons");
@@ -405,6 +406,7 @@ async function parseLoadedData(data, mapVersion) {
pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length);
// data[28] had deprecated cells.crossroad
pack.cells.routes = data[36] ? JSON.parse(data[36]) : {};
+ pack.resources = data[40] ? JSON.parse(data[40]) : {};
if (data[31]) {
const namesDL = data[31].split("/");
@@ -553,6 +555,14 @@ async function parseLoadedData(data, mapVersion) {
ERROR && console.error("[Data integrity] Invalid river", r, "is assigned to cells", invalidCells);
});
+ const invalidResources = [...new Set(cells.r)].filter(r => r && !pack.resources.find(resource => resource.i === r));
+ invalidResources.forEach(r=> {
+ const invalidCells = cells.i.filter(i => cells.r[i] === r);
+ invalidCells.forEach(i => (cells.r[i] = 0));
+ rivers.select("resource" + r).remove();
+ ERROR && console.error("Data integrity check. Invalid resource", r, "is assigned to cells", invalidCells);
+ })
+
pack.burgs.forEach(burg => {
if (typeof burg.capital === "boolean") burg.capital = Number(burg.capital);
diff --git a/modules/io/save.js b/modules/io/save.js
index 96d1d981d..bd0a3432d 100644
--- a/modules/io/save.js
+++ b/modules/io/save.js
@@ -97,6 +97,7 @@ function prepareMapData() {
const religions = JSON.stringify(pack.religions);
const provinces = JSON.stringify(pack.provinces);
const rivers = JSON.stringify(pack.rivers);
+ const resource = JSON.stringify(pack.resources);
const markers = JSON.stringify(pack.markers);
const cellRoutes = JSON.stringify(pack.cells.routes);
const routes = JSON.stringify(pack.routes);
@@ -116,45 +117,47 @@ function prepareMapData() {
// data format as below
const mapData = [
- params,
- settings,
- coords,
- biomes,
- notesData,
- serializedSVG,
- gridGeneral,
- grid.cells.h,
- grid.cells.prec,
- grid.cells.f,
- grid.cells.t,
- grid.cells.temp,
- packFeatures,
- cultures,
- states,
- burgs,
- pack.cells.biome,
- pack.cells.burg,
- pack.cells.conf,
- pack.cells.culture,
- pack.cells.fl,
- pop,
- pack.cells.r,
- [], // deprecated pack.cells.road
- pack.cells.s,
- pack.cells.state,
- pack.cells.religion,
- pack.cells.province,
- [], // deprecated pack.cells.crossroad
- religions,
- provinces,
- namesData,
- rivers,
- rulersString,
- fonts,
- markers,
- cellRoutes,
- routes,
- zones
+ params, //1
+ settings, //2
+ coords, //3
+ biomes, //4
+ notesData, //5
+ serializedSVG, //6
+ gridGeneral, //7
+ grid.cells.h, //8
+ grid.cells.prec, //9
+ grid.cells.f, //10
+ grid.cells.t, //11
+ grid.cells.temp, //12
+ packFeatures, //13
+ cultures, //14
+ states, //15
+ burgs, //16
+ pack.cells.biome, //17
+ pack.cells.burg, //18
+ pack.cells.conf, //19
+ pack.cells.culture, //20
+ pack.cells.fl, //21
+ pop, //22
+ pack.cells.r, //23
+ [], // deprecated pack.cells.road 24
+ pack.cells.s, // 25
+ pack.cells.state, //26
+ pack.cells.religion, //27
+ pack.cells.province, //28
+ [], // deprecated pack.cells.crossroad 29
+ religions, //30
+ provinces, //31
+ namesData, //32
+ rivers, //33
+ rulersString, //34
+ fonts, //35
+ markers, //36
+ cellRoutes, //37
+ routes, //38
+ zones, //39
+ pack.cells.resources, //40
+ resources //41
].join("\r\n");
return mapData;
}
diff --git a/modules/production-generator.js b/modules/production-generator.js
new file mode 100644
index 000000000..9418aecdb
--- /dev/null
+++ b/modules/production-generator.js
@@ -0,0 +1,114 @@
+'use strict';
+
+window.Production = (function () {
+ const BONUS_PRODUCTION = 4;
+ const BIOME_PRODUCTION = [
+ [{resource: 11, production: 0.75}], // marine: fish
+ [{resource: 2, production: 0.5}], // hot desert: stone
+ [{resource: 2, production: 0.5}], // cold desert: stone
+ [
+ {resource: 12, production: 0.4},
+ {resource: 10, production: 0.4}
+ ], // savanna: game 0.75, cattle 0.75
+ [{resource: 10, production: 0.5}], // grassland: cattle
+ [{resource: 9, production: 0.5}], // tropical seasonal forest: grain
+ [
+ {resource: 9, production: 0.5},
+ {resource: 1, production: 0.5}
+ ], // temperate deciduous forest: grain, wood
+ [
+ {resource: 9, production: 0.5},
+ {resource: 1, production: 0.5}
+ ], // tropical rainforest: grain, wood
+ [
+ {resource: 9, production: 0.5},
+ {resource: 1, production: 0.5}
+ ], // temperate rainforest: grain, wood
+ [
+ {resource: 1, production: 0.5},
+ {resource: 12, production: 0.4}
+ ], // taiga: wood, game
+ [{resource: 29, production: 0.5}], // tundra: furs
+ [], // glacier: nothing
+ [
+ {resource: 4, production: 0.4},
+ {resource: 12, production: 0.4}
+ ] // wetland: iron, game
+ ];
+ const RIVER_PRODUCTION = [{resource: 11, production: 0.5}]; // fish
+ const HILLS_PRODUCTION = [{resource: 34, production: 0.5}]; // coal
+ const FOOD_MULTIPLIER = 3;
+
+ const collectResources = () => {
+ const {cells, burgs} = pack;
+
+ for (const burg of burgs) {
+ if (!burg.i || burg.removed) continue;
+
+ const {cell, type, population} = burg;
+
+ const resourcesPull = {};
+ const addResource = (resourceId, production) => {
+ const currentProd = resourcesPull[resourceId] || 0;
+ if (!currentProd) {
+ resourcesPull[resourceId] = production;
+ } else {
+ if (production > currentProd) resourcesPull[resourceId] = production + currentProd / 3;
+ else resourcesPull[resourceId] = currentProd + production / 3;
+ }
+ };
+
+ const cellsInArea = cells.c[cell].concat([cell]);
+ for (const cell of cellsInArea) {
+ cells.resource[cell] && addResource(cells.resource[cell], BONUS_PRODUCTION);
+ BIOME_PRODUCTION[cells.biome[cell]].forEach(({resource, production}) => addResource(resource, production));
+ cells.r[cell] && RIVER_PRODUCTION.forEach(({resource, production}) => addResource(resource, production));
+ cells.h[cell] >= 50 && HILLS_PRODUCTION.forEach(({resource, production}) => addResource(resource, production));
+ }
+
+ const queue = new FlatQueue();
+ for (const resourceId in resourcesPull) {
+ const baseProduction = resourcesPull[resourceId];
+ const resource = Resources.get(+resourceId);
+
+ const cultureModifier = resource.culture[type] || 1;
+ const production = baseProduction * cultureModifier;
+
+ const {value, category} = resource;
+ const isFood = category === 'Food';
+
+ const basePriority = production * value;
+ const priority = basePriority * (isFood ? FOOD_MULTIPLIER : 1);
+ queue.push({resourceId: +resourceId, basePriority, priority, production, isFood},0);
+ }
+
+ let foodProduced = 0;
+ const productionPull = {};
+ const addProduction = (resourceId, production) => {
+ if (!productionPull[resourceId]) productionPull[resourceId] = production;
+ else productionPull[resourceId] += production;
+ };
+
+ for (let i = 0; i < population; i++) {
+ const occupation = queue.pop();
+ const {resourceId, production, basePriority, isFood} = occupation;
+ addProduction(resourceId, production);
+ if (isFood) foodProduced += production;
+
+ const foodModifier = isFood && foodProduced < population ? FOOD_MULTIPLIER : 1;
+ const newBasePriority = basePriority / 2;
+ const newPriority = newBasePriority * foodModifier;
+
+ queue.push({...occupation, basePriority: newBasePriority, priority: newPriority},0);
+ }
+
+ burg.produced = {};
+ for (const resourceId in productionPull) {
+ const production = productionPull[resourceId];
+ burg.produced[resourceId] = Math.ceil(production);
+ }
+ }
+ };
+
+ return {collectResources};
+})();
\ No newline at end of file
diff --git a/modules/resources-generator.js b/modules/resources-generator.js
new file mode 100644
index 000000000..852103a00
--- /dev/null
+++ b/modules/resources-generator.js
@@ -0,0 +1,616 @@
+'use strict';
+
+window.Resources = (function () {
+ let cells, cellId;
+
+ const defaultResources = [
+ {
+ i: 1,
+ name: 'Wood',
+ category: 'Construction',
+ icon: 'resource-wood',
+ color: '#966F33',
+ value: 2,
+ chance: 4,
+ model: 'Any_forest',
+ unit: 'pile',
+ bonus: {fleet: 2, defence: 1},
+ culture: {Hunting: 2}
+ },
+ {
+ i: 2,
+ name: 'Stone',
+ category: 'Construction',
+ icon: 'resource-stone',
+ color: '#979EA2',
+ value: 2,
+ chance: 4,
+ model: 'Hills',
+ unit: 'pallet',
+ bonus: {prestige: 1, defence: 2},
+ culture: {Hunting: 0.6, Nomadic: 0.6}
+ },
+ {
+ i: 3,
+ name: 'Marble',
+ category: 'Construction',
+ icon: 'resource-marble',
+ color: '#d6d0bf',
+ value: 7,
+ chance: 1,
+ model: 'Mountains',
+ unit: 'pallet',
+ bonus: {prestige: 2},
+ culture: {Highland: 2}
+ },
+ {
+ i: 4,
+ name: 'Iron',
+ category: 'Ore',
+ icon: 'resource-iron',
+ color: '#5D686E',
+ value: 4,
+ chance: 4,
+ model: 'Mountains_and_wetlands',
+ unit: 'wagon',
+ bonus: {artillery: 1, infantry: 1, defence: 1},
+ culture: {Highland: 2}
+ },
+ {
+ i: 5,
+ name: 'Copper',
+ category: 'Ore',
+ icon: 'resource-copper',
+ color: '#b87333',
+ value: 5,
+ chance: 3,
+ model: 'Mountains',
+ unit: 'wagon',
+ bonus: {artillery: 2, defence: 1, prestige: 1},
+ culture: {Highland: 2}
+ },
+ {
+ i: 6,
+ name: 'Lead',
+ category: 'Ore',
+ icon: 'resource-lead',
+ color: '#454343',
+ value: 4,
+ chance: 3,
+ model: 'Mountains',
+ unit: 'wagon',
+ bonus: {artillery: 1, defence: 1},
+ culture: {Highland: 2}
+ },
+ {
+ i: 7,
+ name: 'Silver',
+ category: 'Ore',
+ icon: 'resource-silver',
+ color: '#C0C0C0',
+ value: 8,
+ chance: 3,
+ model: 'Mountains',
+ unit: 'bullion',
+ bonus: {prestige: 2},
+ culture: {Hunting: 0.5, Highland: 2, Nomadic: 0.5}
+ },
+ {
+ i: 8,
+ name: 'Gold',
+ category: 'Ore',
+ icon: 'resource-gold',
+ color: '#d4af37',
+ value: 15,
+ chance: 1,
+ model: 'Headwaters',
+ unit: 'bullion',
+ bonus: {prestige: 3},
+ culture: {Highland: 2, Nomadic: 0.5}
+ },
+ {
+ i: 9,
+ name: 'Grain',
+ category: 'Food',
+ icon: 'resource-grain',
+ color: '#F5DEB3',
+ value: 1,
+ chance: 4,
+ model: 'More_habitable',
+ unit: 'wain',
+ bonus: {population: 4},
+ culture: {River: 3, Lake: 2, Nomadic: 0.5}
+ },
+ {
+ i: 10,
+ name: 'Cattle',
+ category: 'Food',
+ icon: 'resource-cattle',
+ color: '#56b000',
+ value: 2,
+ chance: 4,
+ model: 'Pastures_and_temperate_forest',
+ unit: 'head',
+ bonus: {population: 2},
+ culture: {Nomadic: 3}
+ },
+ {
+ i: 11,
+ name: 'Fish',
+ category: 'Food',
+ icon: 'resource-fish',
+ color: '#7fcdff',
+ value: 1,
+ chance: 2,
+ model: 'Marine_and_rivers',
+ unit: 'wain',
+ bonus: {population: 2},
+ culture: {River: 2, Lake: 3, Naval: 3, Nomadic: 0.5}
+ },
+ {
+ i: 12,
+ name: 'Game',
+ category: 'Food',
+ icon: 'resource-game',
+ color: '#c38a8a',
+ value: 2,
+ chance: 3,
+ model: 'Any_forest',
+ unit: 'wain',
+ bonus: {archers: 2, population: 1},
+ culture: {Naval: 0.6, Nomadic: 2, Hunting: 3}
+ },
+ {
+ i: 13,
+ name: 'Wine',
+ category: 'Food',
+ icon: 'resource-wine',
+ color: '#963e48',
+ value: 2,
+ chance: 3,
+ model: 'Tropical_forests',
+ unit: 'barrel',
+ bonus: {population: 1, prestige: 1},
+ culture: {Highland: 1.2, Nomadic: 0.5}
+ },
+ {
+ i: 14,
+ name: 'Olives',
+ category: 'Food',
+ icon: 'resource-olives',
+ color: '#BDBD7D',
+ value: 2,
+ chance: 3,
+ model: 'Tropical_forests',
+ unit: 'barrel',
+ bonus: {population: 1},
+ culture: {Generic: 0.8, Nomadic: 0.5}
+ },
+ {
+ i: 15,
+ name: 'Honey',
+ category: 'Preservative',
+ icon: 'resource-honey',
+ color: '#DCBC66',
+ value: 2,
+ chance: 3,
+ model: 'Temperate_and_boreal_forests',
+ unit: 'barrel',
+ bonus: {population: 1},
+ culture: {Hunting: 2, Highland: 2}
+ },
+ {
+ i: 16,
+ name: 'Salt',
+ category: 'Preservative',
+ icon: 'resource-salt',
+ color: '#E5E4E5',
+ value: 3,
+ chance: 3,
+ model: 'Arid_land_and_salt_lakes',
+ unit: 'bag',
+ bonus: {population: 1, defence: 1},
+ culture: {Naval: 1.2, Nomadic: 1.4}
+ },
+ {
+ i: 17,
+ name: 'Dates',
+ category: 'Food',
+ icon: 'resource-dates',
+ color: '#dbb2a3',
+ value: 2,
+ chance: 2,
+ model: 'Hot_desert',
+ unit: 'wain',
+ bonus: {population: 1},
+ culture: {Hunting: 0.8, Highland: 0.8}
+ },
+ {
+ i: 18,
+ name: 'Horses',
+ category: 'Supply',
+ icon: 'resource-horses',
+ color: '#ba7447',
+ value: 5,
+ chance: 4,
+ model: 'Grassland_and_cold_desert',
+ unit: 'head',
+ bonus: {cavalry: 2},
+ culture: {Nomadic: 3}
+ },
+ {
+ i: 19,
+ name: 'Elephants',
+ category: 'Supply',
+ icon: 'resource-elephants',
+ color: '#C5CACD',
+ value: 7,
+ chance: 2,
+ model: 'Hot_biomes',
+ unit: 'head',
+ bonus: {cavalry: 1},
+ culture: {Nomadic: 1.2, Highland: 0.5}
+ },
+ {
+ i: 20,
+ name: 'Camels',
+ category: 'Supply',
+ icon: 'resource-camels',
+ color: '#C19A6B',
+ value: 7,
+ chance: 3,
+ model: 'Deserts',
+ unit: 'head',
+ bonus: {cavalry: 1},
+ culture: {Nomadic: 3}
+ },
+ {
+ i: 21,
+ name: 'Hemp',
+ category: 'Material',
+ icon: 'resource-hemp',
+ color: '#069a06',
+ value: 2,
+ chance: 3,
+ model: 'Deciduous_forests',
+ unit: 'wain',
+ bonus: {fleet: 2},
+ culture: {River: 2, Lake: 2, Naval: 2}
+ },
+ {
+ i: 22,
+ name: 'Pearls',
+ category: 'Luxury',
+ icon: 'resource-pearls',
+ color: '#EAE0C8',
+ value: 16,
+ chance: 2,
+ model: 'Tropical_waters',
+ unit: 'pearl',
+ bonus: {prestige: 1},
+ culture: {Naval: 3}
+ },
+ {
+ i: 23,
+ name: 'Gemstones',
+ category: 'Luxury',
+ icon: 'resource-gemstones',
+ color: '#e463e4',
+ value: 17,
+ chance: 2,
+ model: 'Mountains',
+ unit: 'stone',
+ bonus: {prestige: 1},
+ culture: {Naval: 2}
+ },
+ {
+ i: 24,
+ name: 'Dyes',
+ category: 'Luxury',
+ icon: 'resource-dyes',
+ color: '#fecdea',
+ value: 6,
+ chance: 0.5,
+ model: 'Habitable_biome_or_marine',
+ unit: 'bag',
+ bonus: {prestige: 1},
+ culture: {Generic: 2}
+ },
+ {
+ i: 25,
+ name: 'Incense',
+ category: 'Luxury',
+ icon: 'resource-incense',
+ color: '#ebe5a7',
+ value: 12,
+ chance: 2,
+ model: 'Hot_desert_and_tropical_forest',
+ unit: 'chest',
+ bonus: {prestige: 2},
+ culture: {Generic: 2}
+ },
+ {
+ i: 26,
+ name: 'Silk',
+ category: 'Luxury',
+ icon: 'resource-silk',
+ color: '#e0f0f8',
+ value: 15,
+ chance: 1,
+ model: 'Tropical_rainforest',
+ unit: 'bolt',
+ bonus: {prestige: 2},
+ culture: {River: 1.2, Lake: 1.2}
+ },
+ {
+ i: 27,
+ name: 'Spices',
+ category: 'Luxury',
+ icon: 'resource-spices',
+ color: '#e99c75',
+ value: 15,
+ chance: 2,
+ model: 'Tropical_rainforest',
+ unit: 'chest',
+ bonus: {prestige: 2},
+ culture: {Generic: 2}
+ },
+ {
+ i: 28,
+ name: 'Amber',
+ category: 'Luxury',
+ icon: 'resource-amber',
+ color: '#e68200',
+ value: 7,
+ chance: 2,
+ model: 'Foresty_seashore',
+ unit: 'stone',
+ bonus: {prestige: 1},
+ culture: {Generic: 2}
+ },
+ {
+ i: 29,
+ name: 'Furs',
+ category: 'Material',
+ icon: 'resource-furs',
+ color: '#8a5e51',
+ value: 6,
+ chance: 2,
+ model: 'Boreal_forests',
+ unit: 'pelt',
+ bonus: {prestige: 1},
+ culture: {Hunting: 3}
+ },
+ {
+ i: 30,
+ name: 'Sheep',
+ category: 'Material',
+ icon: 'resource-sheeps',
+ color: '#53b574',
+ value: 2,
+ chance: 3,
+ model: 'Pastures_and_temperate_forest',
+ unit: 'head',
+ bonus: {infantry: 1},
+ culture: {Naval: 2, Highland: 2}
+ },
+ {
+ i: 31,
+ name: 'Slaves',
+ category: 'Supply',
+ icon: 'resource-slaves',
+ color: '#757575',
+ value: 5,
+ chance: 2,
+ model: 'Less_habitable_seashore',
+ unit: 'slave',
+ bonus: {population: 2},
+ culture: {Naval: 2, Nomadic: 3, Hunting: 0.6, Highland: 0.4}
+ },
+ {
+ i: 32,
+ name: 'Tar',
+ category: 'Material',
+ icon: 'resource-tar',
+ color: '#727272',
+ value: 2,
+ chance: 3,
+ model: 'Any_forest',
+ unit: 'barrel',
+ bonus: {fleet: 1},
+ culture: {Hunting: 3}
+ },
+ {
+ i: 33,
+ name: 'Saltpeter',
+ category: 'Material',
+ icon: 'resource-saltpeter',
+ color: '#e6e3e3',
+ value: 3,
+ chance: 2,
+ model: 'Less_habitable_biomes',
+ unit: 'barrel',
+ bonus: {artillery: 3},
+ culture: {Generic: 2}
+ },
+ {
+ i: 34,
+ name: 'Coal',
+ category: 'Material',
+ icon: 'resource-coal',
+ color: '#36454f',
+ value: 2,
+ chance: 3,
+ model: 'Hills',
+ unit: 'wain',
+ bonus: {artillery: 2},
+ culture: {Generic: 2}
+ },
+ {
+ i: 35,
+ name: 'Oil',
+ category: 'Material',
+ icon: 'resource-oil',
+ color: '#565656',
+ value: 3,
+ chance: 2,
+ model: 'Less_habitable_biomes',
+ unit: 'barrel',
+ bonus: {artillery: 1},
+ culture: {Generic: 2, Nomadic: 2}
+ },
+ {
+ i: 36,
+ name: 'Tropical timber',
+ category: 'Luxury',
+ icon: 'resource-tropicalTimber',
+ color: '#a45a52',
+ value: 10,
+ chance: 2,
+ model: 'Tropical_rainforest',
+ unit: 'pile',
+ bonus: {prestige: 1},
+ culture: {Generic: 2}
+ },
+ {
+ i: 37,
+ name: 'Whales',
+ category: 'Food',
+ icon: 'resource-whales',
+ color: '#cccccc',
+ value: 2,
+ chance: 3,
+ model: 'Arctic_waters',
+ unit: 'barrel',
+ bonus: {population: 1},
+ culture: {Naval: 2}
+ },
+ {
+ i: 38,
+ name: 'Sugar',
+ category: 'Preservative',
+ icon: 'resource-sugar',
+ color: '#7abf87',
+ value: 3,
+ chance: 3,
+ model: 'Tropical_rainforest',
+ unit: 'bag',
+ bonus: {population: 1},
+ culture: {Lake: 2, River: 2}
+ },
+ {
+ i: 39,
+ name: 'Tea',
+ category: 'Luxury',
+ icon: 'resource-tea',
+ color: '#d0f0c0',
+ value: 5,
+ chance: 3,
+ model: 'Hilly_tropical_rainforest',
+ unit: 'bag',
+ bonus: {prestige: 1},
+ culture: {Lake: 2, River: 2, Highland: 2}
+ },
+ {
+ i: 40,
+ name: 'Tobacco',
+ category: 'Luxury',
+ icon: 'resource-tobacco',
+ color: '#6D5843',
+ value: 5,
+ chance: 2,
+ model: 'Tropical_rainforest',
+ unit: 'bag',
+ bonus: {prestige: 1},
+ culture: {Lake: 2, River: 2}
+ }
+ ];
+
+ const models = {
+ Deciduous_forests: 'biome(6, 7, 8)',
+ Any_forest: 'biome(5, 6, 7, 8, 9)',
+ Temperate_and_boreal_forests: 'biome(6, 8, 9)',
+ Hills: 'minHeight(40) || (minHeight(30) && nth(10))',
+ Mountains: 'minHeight(60) || (minHeight(20) && nth(10))',
+ Mountains_and_wetlands: 'minHeight(60) || (biome(12) && nth(7)) || (minHeight(20) && nth(10))',
+ Headwaters: 'river() && minHeight(40)',
+ More_habitable: 'minHabitability(20) && habitability()',
+ Marine_and_rivers: 'shore(-1) && (type("ocean", "freshwater", "salt") || (river() && shore(1, 2)))',
+ Pastures_and_temperate_forest: '(biome(3, 4) && !elevation()) || (biome(6) && random(70)) || (biome(5) && nth(5))',
+ Tropical_forests: 'biome(5, 7)',
+ Arid_land_and_salt_lakes: 'shore(1) && type("salt", "dry") || (biome(1, 2) && random(70)) || (biome(12) && nth(10))',
+ Hot_desert: 'biome(1)',
+ Deserts: 'biome(1, 2)',
+ Grassland_and_cold_desert: 'biome(3) || (biome(2) && nth(4))',
+ Hot_biomes: 'biome(1, 3, 5, 7)',
+ Hot_desert_and_tropical_forest: 'biome(1, 7)',
+ Tropical_rainforest: 'biome(7)',
+ Tropical_waters: 'shore(-1) && minTemp(18)',
+ Hilly_tropical_rainforest: 'minHeight(40) && biome(7)',
+ Subtropical_waters: 'shore(-1) && minTemp(14)',
+ Habitable_biome_or_marine: 'shore(-1) || minHabitability(1)',
+ Foresty_seashore: 'shore(1) && biome(6, 7, 8, 9)',
+ Boreal_forests: 'biome(9) || (biome(10) && nth(2)) || (biome(6, 8) && nth(5)) || (biome(12) && nth(10))',
+ Less_habitable_seashore: 'shore(1) && minHabitability(1) && !habitability()',
+ Less_habitable_biomes: 'minHabitability(1) && !habitability()',
+ Arctic_waters: 'shore(-1) && biome(0) && maxTemp(7)'
+ };
+
+ const methods = {
+ random: (number) => number >= 100 || (number > 0 && number / 100 > Math.random()),
+ nth: (number) => !(cellId % number),
+ minHabitability: (min) => biomesData.habitability[pack.cells.biome[cellId]] >= min,
+ habitability: () => biomesData.habitability[cells.biome[cellId]] > Math.random() * 100,
+ elevation: () => pack.cells.h[cellId] / 100 > Math.random(),
+ biome: (...biomes) => biomes.includes(pack.cells.biome[cellId]),
+ minHeight: (heigh) => pack.cells.h[cellId] >= heigh,
+ maxHeight: (heigh) => pack.cells.h[cellId] <= heigh,
+ minTemp: (temp) => grid.cells.temp[pack.cells.g[cellId]] >= temp,
+ maxTemp: (temp) => grid.cells.temp[pack.cells.g[cellId]] <= temp,
+ shore: (...rings) => rings.includes(pack.cells.t[cellId]),
+ type: (...types) => types.includes(pack.features[cells.f[cellId]].group),
+ river: () => pack.cells.r[cellId]
+ };
+ const allMethods = '{' + Object.keys(methods).join(', ') + '}';
+
+ const generate = function () {
+ TIME && console.time('generateResources');
+ cells = pack.cells;
+ cells.resource = new Uint8Array(cells.i.length); // resources array [0, 255]
+ const resourceMaxCells = Math.ceil((200 * cells.i.length) / 5000);
+ if (!pack.resources) pack.resources = defaultResources;
+ pack.resources.forEach((r) => {
+ r.cells = 0;
+ const model = r.custom || models[r.model];
+ r.fn = new Function(allMethods, 'return ' + model);
+ });
+
+ const skipGlaciers = biomesData.habitability[11] === 0;
+ const shuffledCells = d3.shuffle(cells.i.slice());
+
+ for (const i of shuffledCells) {
+ if (!(i % 10)) d3.shuffle(pack.resources);
+ if (skipGlaciers && cells.biome[i] === 11) continue;
+ const rnd = Math.random() * 100;
+ cellId = i;
+
+ for (const resource of pack.resources) {
+ if (resource.cells >= resourceMaxCells) continue;
+ if (resource.cells ? rnd > resource.chance : Math.random() * 100 > resource.chance) continue;
+ if (!resource.fn({...methods})) continue;
+
+ cells.resource[i] = resource.i;
+ resource.cells++;
+ break;
+ }
+ }
+ pack.resources.sort((a, b) => (a.i > b.i ? 1 : -1)).forEach((r) => delete r.fn);
+
+ TIME && console.timeEnd('generateResources');
+ };
+
+ const getStroke = (color) => d3.color(color).darker(2).hex();
+ const get = (i) => pack.resources.find((resource) => resource.i === i);
+
+ return {generate, methods, models, getStroke, get};
+})();
diff --git a/modules/trade-generator.js b/modules/trade-generator.js
new file mode 100644
index 000000000..0615a9209
--- /dev/null
+++ b/modules/trade-generator.js
@@ -0,0 +1,239 @@
+'use strict';
+
+window.Trade = (function () {
+ const defineCenters = () => {
+ TIME && console.time('defineCenters');
+ pack.trade = {centers: [], deals: []};
+ const {burgs, trade} = pack;
+
+ // min distance between trade centers
+ let minSpacing = (((graphWidth + graphHeight) * 2) / burgs.length ** 0.7) | 0;
+
+ const tradeScore = burgs.map(({i, removed, capital, port, population, produced}) => {
+ if (!i || removed) return {i: 0, score: 0};
+ const totalProduction = d3.sum(Object.values(produced));
+ let score = Math.round(totalProduction - population);
+ if (capital) score *= 2;
+ if (port) score *= 2;
+ return {i, score};
+ });
+
+ const candidatesSorted = tradeScore.sort((a, b) => b.score - a.score);
+ const centersTree = d3.quadtree();
+
+ for (const {i: burgId} of candidatesSorted) {
+ if (!burgId) continue;
+ const burg = burgs[burgId];
+ const {x, y} = burg;
+
+ const tradeCenter = centersTree.find(x, y, minSpacing);
+
+ if (tradeCenter) {
+ const tradeCenterId = tradeCenter[2];
+ burg.tradeCenter = tradeCenterId;
+ trade.centers[tradeCenterId].burgs.push({i: burgId});
+ } else {
+ const tradeCenterId = trade.centers.length;
+ trade.centers.push({i: tradeCenterId, burg: burgId, burgs: [{i: burgId}]});
+ centersTree.add([x, y, tradeCenterId]);
+ burg.tradeCenter = tradeCenterId;
+ }
+
+ minSpacing += 1;
+ }
+
+ // TODO: remove debug rendering
+ for (const burg of burgs) {
+ const {i, x: x1, y: y1, tradeCenter} = burg;
+ if (!i) continue;
+
+ const center = trade.centers[tradeCenter];
+ const {x: x2, y: y2} = burgs[center.burg];
+ debug.append('line').attr('x1', x1).attr('y1', y1).attr('x2', x2).attr('y2', y2).attr('stroke', 'black').attr('stroke-width', 0.2);
+ }
+ for (const {i, score} of candidatesSorted) {
+ if (!i) continue;
+ const {x, y, capital} = burgs[i];
+ debug
+ .append('text')
+ .attr('x', x)
+ .attr('y', y)
+ .style('font-size', capital ? 5 : 3)
+ .style('fill', 'blue')
+ .text(score);
+ }
+ for (const tradeCenter of trade.centers) {
+ const {x, y} = burgs[tradeCenter.burg];
+ debug
+ .append('circle')
+ .attr('cx', x - 4)
+ .attr('cy', y - 4)
+ .attr('r', 2)
+ .style('stroke', '#000')
+ .style('stroke-width', 0.2)
+ .style('fill', 'white');
+ debug
+ .append('text')
+ .attr('x', x - 4)
+ .attr('y', y - 4)
+ .style('font-size', 3)
+ .text(tradeCenter.i);
+ }
+
+ TIME && console.timeEnd('defineCenters');
+ };
+
+ const calculateDistances = () => {
+ TIME && console.time('calculateDistances');
+ const {cells, burgs, trade} = pack;
+ const {centers} = trade;
+
+ const getCost = (dist, sameFeature, sameFeaturePorts) => {
+ if (sameFeaturePorts) return dist / 2;
+ if (sameFeature) return dist;
+ return dist * 1.5;
+ };
+
+ const costs = new Array(centers.length);
+ for (let i = 0; i < centers.length; i++) {
+ costs[i] = new Array(centers.length);
+ const {x: x1, y: y1, port: port1, cell: cell1} = burgs[centers[i].burg];
+
+ for (let j = i + 1; j < centers.length; j++) {
+ const {x: x2, y: y2, port: port2, cell: cell2} = burgs[centers[j].burg];
+ const distance = Math.hypot(x1 - x2, y1 - y2);
+ const sameFeature = cells.f[cell1] === cells.f[cell2];
+ const sameFeaturePorts = port1 && port2 && port1 === port2;
+ costs[i][j] = getCost(distance, sameFeature, sameFeaturePorts) | 0;
+ }
+ }
+
+ for (const center of centers) {
+ // nearers trade centers
+ center.nearest = centers.map(({i}) => {
+ const cost = center.i < i ? costs[center.i][i] : costs[i][center.i];
+ return {i, cost: cost || 0};
+ });
+ center.nearest.sort((a, b) => a.cost - b.cost);
+
+ // distance cost to burgs
+ const {x: x1, y: y1, port: port1, cell: cell1} = burgs[center.burg];
+ center.burgs = center.burgs.map(({i: burgId}) => {
+ const {x: x2, y: y2, port: port2, cell: cell2} = burgs[burgId];
+
+ const distance = Math.hypot(x1 - x2, y1 - y2);
+ const sameFeature = cells.f[cell1] === cells.f[cell2];
+ const sameFeaturePorts = port1 && port2 && port1 === port2;
+ const cost = getCost(distance, sameFeature, sameFeaturePorts) | 0;
+ return {i: burgId, cost};
+ });
+ }
+
+ TIME && console.timeEnd('calculateDistances');
+ };
+
+ const exportGoods = () => {
+ const {burgs, states, trade} = pack;
+ const DEFAULT_TRANSPORT_DIST = (graphWidth + graphHeight) / 20;
+
+ for (const tradeCenter of trade.centers) {
+ const {i: centerId, burgs: centerBurgs} = tradeCenter;
+ const tradeCenterGoods = {};
+
+ for (const {i: burgId, cost: distanceCost} of centerBurgs) {
+ const burg = burgs[burgId];
+ const {i, removed, produced, population, state} = burg;
+ if (!i || removed) continue;
+ const consumption = Math.ceil(population);
+ const exportPool = {};
+
+ const transportFee = (distanceCost / DEFAULT_TRANSPORT_DIST) ** 0.8 || 0.02;
+ const salesTax = states[state].salesTax || 0;
+ let income = 0;
+
+ const categorized = {};
+ for (const resourceId in produced) {
+ const {category} = Resources.get(+resourceId);
+ if (!categorized[category]) categorized[category] = {};
+ categorized[category][resourceId] = produced[resourceId];
+ }
+
+ for (const category in categorized) {
+ const categoryProduction = d3.sum(Object.values(categorized[category]));
+ const exportQuantity = categoryProduction - consumption;
+ if (exportQuantity <= 0) continue;
+
+ for (const resourceId in categorized[category]) {
+ const production = categorized[category][resourceId];
+ const quantity = Math.round((production / categoryProduction) * exportQuantity);
+ if (quantity <= 0) continue;
+
+ const {value, name} = Resources.get(+resourceId);
+
+ const basePrice = value * quantity;
+ const transportCost = rn((value * quantity) ** 0.5 * transportFee, 1);
+ const netPrice = basePrice - transportCost;
+
+ const stateIncome = rn(netPrice * salesTax, 1);
+ const burgIncome = rn(netPrice - stateIncome, 1);
+
+ if (burgIncome < 1 || burgIncome < basePrice / 4) continue;
+
+ trade.deals.push({resourceId: +resourceId, name, quantity, exporter: i, tradeCenter: centerId, basePrice, transportCost, stateIncome, burgIncome});
+ income += burgIncome;
+
+ if (!exportPool[resourceId]) exportPool[resourceId] = quantity;
+ else exportPool[resourceId] += quantity;
+
+ if (!tradeCenterGoods[resourceId]) tradeCenterGoods[resourceId] = quantity;
+ else tradeCenterGoods[resourceId] += quantity;
+ }
+ }
+
+ burg.exported = exportPool;
+ burg.income = income;
+ }
+
+ tradeCenter.supply = tradeCenterGoods;
+ }
+ };
+
+ const importGoods = () => {
+ const {resources, burgs, states, trade} = pack;
+
+ for (const burg of burgs) {
+ const {i, removed, tradeCenter: localTradeCenterId, x, y, produced, population} = burg;
+ if (!i || removed) continue;
+
+ const importPool = {};
+ const localTradeCenter = trade.centers[localTradeCenterId];
+
+ let demand = Math.ceil(population);
+
+ for (const resource of resources) {
+ const {i: resourceId, value, category} = resource;
+ if (produced[resourceId]) continue;
+
+ // check for resource supply on markets starting from closest
+ for (const {i: tradeCenterId, cost: transportCost} of localTradeCenter.nearest) {
+ const tradeCenter = trade.centers[tradeCenterId];
+ const stored = tradeCenter.supply[resourceId];
+ if (!stored) continue;
+
+ const quantity = Math.min(demand, stored);
+ importPool[resourceId] = quantity;
+
+ break;
+
+ tradeCenter.supply[resourceId] -= quantity;
+ demand -= quantity;
+ if (demand <= 0) break;
+ }
+ }
+
+ burg.imported = importPool;
+ }
+ };
+
+ return {defineCenters, calculateDistances, exportGoods, importGoods};
+})();
diff --git a/modules/ui/burg-editor.js b/modules/ui/burg-editor.js
index 13e328509..2d8f6b9a8 100644
--- a/modules/ui/burg-editor.js
+++ b/modules/ui/burg-editor.js
@@ -95,6 +95,12 @@ function editBurg(id) {
if (b.shanty) byId("burgShanty").classList.remove("inactive");
else byId("burgShanty").classList.add("inactive");
+ // economics block
+ byId("burgProduction").innerHTML = getProduction(b.produced);
+ const deals = pack.trade.deals;
+ byId("burgExport").innerHTML = getExport(deals.filter((deal) => deal.exporter === b.i));
+ byId("burgImport").innerHTML = "";
+
//toggle lock
updateBurgLockIcon();
@@ -120,6 +126,38 @@ function editBurg(id) {
}
}
+ function getProduction(pool) {
+ let html = "";
+
+ for (const resourceId in pool) {
+ const {name, unit, icon} = Resources.get(+resourceId);
+ const production = pool[resourceId];
+ const unitName = production > 1 ? unit + 's' : unit;
+
+ html += `
+
+ `;
+ }
+ return html;
+ }
+
+ function getExport(dealsArray) {
+ if (!dealsArray.length) return 'no';
+
+ const totalIncome = rn(d3.sum(dealsArray.map((deal) => deal.burgIncome)));
+ const exported = dealsArray.map((deal) => {
+ const {resourceId, quantity, burgIncome} = deal;
+ const {name, unit, icon} = Resources.get(resourceId);
+ const unitName = quantity > 1 ? unit + 's' : unit;
+
+ return `
+
+ ${quantity}
+ `;
+ });
+ return `${totalIncome}: ${exported.join('')}`;
+ }
+
function dragBurgLabel() {
const tr = parseTransform(this.getAttribute("transform"));
const dx = +tr[0] - d3.event.x,
diff --git a/modules/ui/general.js b/modules/ui/general.js
index 6e276bd6d..365d4e6fa 100644
--- a/modules/ui/general.js
+++ b/modules/ui/general.js
@@ -142,6 +142,14 @@ function showMapTooltip(point, e, i, g) {
return;
}
+ if (group === "goods") {
+ const id = +e.target.dataset.i;
+ const resource = pack.resources.find(res => res.i === id);
+
+ tip("Resource: " + resource.name);
+ return;
+ }
+
if (group === "rivers") {
const river = +e.target.id.slice(5);
const r = pack.rivers.find(r => r.i === river);
diff --git a/modules/ui/heightmap-editor.js b/modules/ui/heightmap-editor.js
index 20fa201c2..38d4a1c8b 100644
--- a/modules/ui/heightmap-editor.js
+++ b/modules/ui/heightmap-editor.js
@@ -238,6 +238,9 @@ function editHeightmap(options) {
}
Biomes.define();
+
+ Resources.generate();
+
rankCells();
Cultures.generate();
diff --git a/modules/ui/hotkeys.js b/modules/ui/hotkeys.js
index 4b1e626d5..dd4e75e19 100644
--- a/modules/ui/hotkeys.js
+++ b/modules/ui/hotkeys.js
@@ -74,6 +74,7 @@ function handleKeyup(event) {
else if (code === "KeyZ") toggleZones();
else if (code === "KeyD") toggleBorders();
else if (code === "KeyR") toggleReligions();
+ else if (code === "KeyQ") toggleResources();
else if (code === "KeyU") toggleRoutes();
else if (code === "KeyT") toggleTemperature();
else if (code === "KeyN") togglePopulation();
diff --git a/modules/ui/layers.js b/modules/ui/layers.js
index 1591a7784..88134d40c 100644
--- a/modules/ui/layers.js
+++ b/modules/ui/layers.js
@@ -59,6 +59,7 @@ function getDefaultPresets() {
"toggleScaleBar",
"toggleVignette"
],
+ economical: ['toggleResources', 'toggleBiomes', 'toggleBorders', 'toggleIcons', 'toggleIce', 'toggleLabels', 'toggleRivers', 'toggleRoutes', 'toggleScaleBar'],
military: [
"toggleBorders",
"toggleBurgIcons",
@@ -968,6 +969,49 @@ function toggleEmblems(event) {
}
}
+function toggleResources(event) {
+ if (!layerIsOn('toggleResources')) {
+ turnButtonOn('toggleResources');
+ drawResources();
+ if (event && isCtrlClick(event)) editStyle('goods');
+ } else {
+ if (event && isCtrlClick(event)) {
+ editStyle('goods');
+ return;
+ }
+ goods.selectAll('*').remove();
+ turnButtonOff('toggleResources');
+ }
+}
+
+function drawResources() {
+ console.time('drawResources');
+ const someArePinned = pack.resources.some((resource) => resource.pinned);
+ // const drawCircle = +goods.attr('data-circle');
+
+ let resourcesHTML = '';
+ for (const i of pack.cells.i) {
+ if (!pack.cells.resource[i]) continue;
+ const resource = Resources.get(pack.cells.resource[i]);
+ if (someArePinned && !resource.pinned) continue;
+ const [x, y] = pack.cells.p[i];
+ const stroke = Resources.getStroke(resource.color);
+
+ // if (!drawCircle) {
+ // resourcesHTML += ``;
+ // continue;
+ // }
+
+ resourcesHTML += ``;
+ }
+
+ goods.html(resourcesHTML);
+ console.timeEnd('drawResources');
+}
+
function toggleVignette(event) {
if (!layerIsOn("toggleVignette")) {
turnButtonOn("toggleVignette");
@@ -1034,6 +1078,7 @@ function getLayer(id) {
if (id === "togglePopulation") return $("#population");
if (id === "toggleIce") return $("#ice");
if (id === "toggleTexture") return $("#texture");
+ if (id === "toggleResources") return $("#goods")
if (id === "toggleEmblems") return $("#emblems");
if (id === "toggleLabels") return $("#labels");
if (id === "toggleBurgIcons") return $("#icons");
diff --git a/modules/ui/resources-editor.js b/modules/ui/resources-editor.js
new file mode 100644
index 000000000..9f61bb382
--- /dev/null
+++ b/modules/ui/resources-editor.js
@@ -0,0 +1,646 @@
+"use strict";
+
+function editResources() {
+ if (customization) return;
+ closeDialogs("#resourcesEditor, .stable");
+ if (!layerIsOn("toggleResources")) toggleResources();
+ const body = byId("resourcesBody");
+
+ resourcesEditorAddLines();
+
+ if (modules.editResources) return;
+ modules.editResources = true;
+
+ $("#resourcesEditor").dialog({
+ title: "Resources Editor",
+ resizable: false,
+ width: fitContent(),
+ close: closeResourcesEditor,
+ position: {my: "right top", at: "right-10 top+10", of: "svg"}
+ });
+
+ // add listeners
+ byId("resourcesEditorRefresh").addEventListener("click", resourcesEditorAddLines);
+ byId("resourcesRegenerate").addEventListener("click", regenerateCurrentResources);
+ byId("resourcesLegend").addEventListener("click", toggleLegend);
+ byId("resourcesPercentage").addEventListener("click", togglePercentageMode);
+ byId("resourcesAssign").addEventListener("click", enterResourceAssignMode);
+ byId("resourcesAdd").addEventListener("click", resourceAdd);
+ byId("resourcesRestore").addEventListener("click", resourcesRestoreDefaults);
+ byId("resourcesExport").addEventListener("click", downloadResourcesData);
+ byId("resourcesUnpinAll").addEventListener("click", unpinAllResources);
+
+ body.addEventListener("click", function (ev) {
+ const el = ev.target,
+ cl = el.classList,
+ line = el.parentNode;
+ const resource = Resources.get(+line.dataset.id);
+ if (cl.contains("resourceIcon")) return changeIcon(resource, line, el);
+ if (cl.contains("resourceCategory")) return changeCategory(resource, line, el);
+ if (cl.contains("resourceModel")) return changeModel(resource, line, el);
+ if (cl.contains("resourceBonus")) return changeBonus(resource, line, el);
+ if (cl.contains("icon-pin")) return pinResource(resource, el);
+ if (cl.contains("icon-trash-empty")) return removeResource(resource, line);
+ });
+
+ body.addEventListener("change", function (ev) {
+ const el = ev.target,
+ cl = el.classList,
+ line = el.parentNode;
+ const resource = Resources.get(+line.dataset.id);
+ if (cl.contains("resourceName")) return changeName(resource, el.value, line);
+ if (cl.contains("resourceValue")) return changeValue(resource, el.value, line);
+ if (cl.contains("resourceChance")) return changeChance(resource, el.value, line);
+ });
+
+ function getBonusIcon(bonus) {
+ if (bonus === "fleet") return ``;
+ if (bonus === "defence") return ``;
+ if (bonus === "prestige") return ``;
+ if (bonus === "artillery") return ``;
+ if (bonus === "infantry") return ``;
+ if (bonus === "population") return ``;
+ if (bonus === "archers") return ``;
+ if (bonus === "cavalry") return ``;
+ }
+
+ // add line for each resource
+ function resourcesEditorAddLines() {
+ const addTitle = (string, max) => (string.length < max ? "" : `title="${string}"`);
+ let lines = "";
+
+ for (const r of pack.resources) {
+ const stroke = Resources.getStroke(r.color);
+ const model = r.model.replaceAll("_", " ");
+ const bonusArray = Object.entries(r.bonus)
+ .map((e) => Array(e[1]).fill(e[0]))
+ .flat();
+ const bonusHTML = bonusArray.map((bonus) => getBonusIcon(bonus)).join("");
+ const bonusString = Object.entries(r.bonus)
+ .map((e) => e.join(": "))
+ .join("; ");
+
+ lines += `
+
+
+ ${r.category}
+
+ ${r.cells}
+
+ ${model}
+
+ ${bonusHTML || "place"}
+
+
+
+ `;
+ }
+ body.innerHTML = lines;
+
+ // update footer
+ byId("resourcesNumber").innerHTML = pack.resources.length;
+
+ // add listeners
+ body.querySelectorAll("div.states").forEach((el) => el.addEventListener("click", selectResourceOnLineClick));
+
+ if (body.dataset.type === "percentage") {
+ body.dataset.type = "absolute";
+ togglePercentageMode();
+ }
+ applySorting(resourcesHeader);
+ $("#resourcesEditor").dialog({width: fitContent()});
+ }
+
+ function changeCategory(resource, line, el) {
+ const categories = [...new Set(pack.resources.map((r) => r.category))].sort();
+ const categoryOptions = (category) => categories.map((c) => ``).join("");
+
+ alertMessage.innerHTML = `
+
+ Select category:
+
+
+
+
+ `;
+
+ $("#alert").dialog({
+ resizable: false,
+ title: "Change category",
+ buttons: {
+ Cancel: function () {
+ $(this).dialog("close");
+ },
+ Apply: function () {
+ applyChanges();
+ $(this).dialog("close");
+ }
+ }
+ });
+
+ function applyChanges() {
+ const custom = byId("resouceCategoryAdd").value;
+ const select = byId("resouceCategorySelect").value;
+ const category = custom ? capitalize(custom) : select;
+ resource.category = line.dataset.category = el.innerHTML = category;
+ }
+ }
+
+ function changeModel(resource, line, el) {
+ const model = line.dataset.model;
+ const modelOptions = Object.keys(models)
+ .sort()
+ .map((m) => ``)
+ .join("");
+ const wikiURL = "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Resources:-spread-functions";
+ const onSelect = "resouceModelFunction.innerHTML = Resources.models[this.value] || ' '; resouceModelCustomName.value = ''; resouceModelCustomFunction.value = ''";
+
+ alertMessage.innerHTML = `
+
+
+
+
+
+ `;
+
+ $("#alert").dialog({
+ resizable: false,
+ title: "Change spread model",
+ buttons: {
+ Help: () => openURL(wikiURL),
+ Cancel: function () {
+ $(this).dialog("close");
+ },
+ Apply: function () {
+ applyChanges(this);
+ }
+ }
+ });
+
+ function applyChanges(dialog) {
+ const customName = byId("resouceModelCustomName").value;
+ const customFn = byId("resouceModelCustomFunction").value;
+
+ const message = byId("resourceModelMessage");
+ if (customName && !customFn) return (message.innerHTML = 'Error. Custom model function is required');
+ if (!customName && customFn) return (message.innerHTML = 'Error. Custom model name is required');
+ message.innerHTML = '';
+
+ if (customName && customFn) {
+ try {
+ const allMethods = '{' + Object.keys(Resources.methods).join(', ') + '}';
+ const fn = new Function(allMethods, 'return ' + customFn);
+ fn({...Resources.methods});
+ } catch (err) {
+ message.innerHTML = 'Error. ' + err.message || err;
+ return;
+ }
+
+ resource.model = line.dataset.model = el.innerHTML = customName;
+ el.setAttribute('title', customName.length > 7 ? customName : '');
+ resource.custom = customFn;
+ $(dialog).dialog('close');
+ return;
+ }
+
+ const model = byId('resouceModelSelect').value;
+ if (!model) return (message.innerHTML = 'Error. Model is not set');
+
+ resource.model = line.dataset.model = el.innerHTML = model;
+ el.setAttribute('title', model.length > 7 ? model : '');
+ $(dialog).dialog('close');
+ }
+ }
+
+ function changeBonus(resource, line, el) {
+ const bonuses = [...new Set(pack.resources.map((r) => Object.keys(r.bonus)).flat())].sort();
+ const inputs = bonuses.map(
+ (bonus) => `
+ ${getBonusIcon(bonus)}
+ ${capitalize(bonus)}
+
+ `
+ );
+
+ alertMessage.innerHTML = inputs.join('');
+ $('#alert').dialog({
+ resizable: false,
+ title: 'Change bonus',
+ buttons: {
+ Cancel: function () {
+ $(this).dialog('close');
+ },
+ Apply: function () {
+ applyChanges();
+ $(this).dialog('close');
+ }
+ }
+ });
+
+ function applyChanges() {
+ const bonusObj = {};
+ bonuses.forEach((bonus) => {
+ const el = byId('resourceBonus_' + bonus);
+ const value = parseInt(el.value);
+ if (isNaN(value) || !value) return;
+ bonusObj[bonus] = value;
+ });
+
+ const bonusArray = Object.entries(bonusObj).map(e => Array(e[1]).fill(e[0])).flat(); //prettier-ignore
+ const bonusHTML = bonusArray.map((bonus) => getBonusIcon(bonus)).join('');
+ const bonusString = Object.entries(bonusObj).map((e) => e.join(': ')).join('; '); //prettier-ignore
+
+ resource.bonus = bonusObj;
+ el.innerHTML = bonusHTML || "place";
+ line.dataset.bonus = bonusString;
+ el.setAttribute('title', bonusString);
+ }
+ }
+
+ function changeName(resource, name, line) {
+ resource.name = line.dataset.name = name;
+ }
+
+ function changeValue(resource, value, line) {
+ resource.value = line.dataset.value = +value;
+ }
+
+ function changeChance(resource, chance, line) {
+ resource.chance = line.dataset.chance = +chance;
+ }
+
+ function changeIcon(resource, line, el) {
+ const standardIcons = Array.from(byId('resource-icons').querySelectorAll('symbol')).map((el) => el.id);
+ const standardIconsOptions = standardIcons.map((icon) => ``);
+
+ const customIconsEl = byId('defs-icons');
+ const customIcons = customIconsEl ? Array.from(byId('defs-icons').querySelectorAll('svg')).map((el) => el.id) : [];
+ const customIconsOptions = customIcons.map((icon) => ``);
+
+ const select = byId('resourceSelectIcon');
+ select.innerHTML = standardIconsOptions + customIconsOptions;
+ select.value = resource.icon;
+
+ const preview = byId('resourceIconPreview');
+ preview.setAttribute('href', '#' + resource.icon);
+
+ const viewBoxSection = byId('resourceIconEditorViewboxFields');
+ viewBoxSection.style.display = 'none';
+
+ $('#resourceIconEditor').dialog({
+ resizable: false,
+ title: 'Change Icon',
+ buttons: {
+ Cancel: function () {
+ $(this).dialog('close');
+ },
+ 'Change color': () => changeColor(resource, line, el),
+ Apply: function () {
+ $(this).dialog('close');
+
+ resource.icon = select.value;
+ line.querySelector('svg.resourceIcon > use').setAttribute('href', '#' + select.value);
+ drawResources();
+ }
+ },
+ position: {my: 'center bottom', at: 'center', of: 'svg'}
+ });
+
+ const uploadTo = byId('defs-icons');
+ const onUpload = (type, id) => {
+ preview.setAttribute('href', '#' + id);
+ select.innerHTML += ``;
+ select.value = id;
+
+ if (type === 'image') return;
+
+ // let user set viewBox for svg image
+ const el = byId(id);
+ viewBoxSection.style.display = 'block';
+ const viewBoxAttr = el.getAttribute('viewBox');
+ const initialViewBox = viewBoxAttr ? viewBoxAttr.split(' ') : [0, 0, 200, 200];
+ const inputs = viewBoxSection.querySelectorAll('input');
+ const changeInput = () => {
+ const viewBox = Array.from(inputs)
+ .map((input) => input.value)
+ .join(' ');
+ el.setAttribute('viewBox', viewBox);
+ };
+ inputs.forEach((input, i) => {
+ input.value = initialViewBox[i];
+ input.onchange = changeInput;
+ });
+ };
+
+ // add listeners
+ select.onchange = () => preview.setAttribute('href', '#' + select.value);
+ byId('resourceUploadIconRaster').onclick = () => imageToLoad.click();
+ byId('resourceUploadIconVector').onclick = () => svgToLoad.click();
+ byId('imageToLoad').onchange = () => uploadImage('image', uploadTo, onUpload);
+ byId('svgToLoad').onchange = () => uploadImage('svg', uploadTo, onUpload);
+ }
+
+ function uploadImage(type, uploadTo, callback) {
+ const input = type === 'image' ? byId('imageToLoad') : byId('svgToLoad');
+ const file = input.files[0];
+ input.value = '';
+
+ if (file.size > 200000) return tip(`File is too big, please optimize file size up to 200kB and re-upload. Recommended size is 48x48 px and up to 10kB`, true, 'error', 5000);
+
+ const reader = new FileReader();
+ reader.onload = function (readerEvent) {
+ const result = readerEvent.target.result;
+ const id = 'resource-custom-' + Math.random().toString(36).slice(-6);
+
+ if (type === 'image') {
+ const svg = ``;
+ uploadTo.insertAdjacentHTML('beforeend', svg);
+ } else {
+ const el = document.createElement('html');
+ el.innerHTML = result;
+
+ // remove sodipodi and inkscape attributes
+ el.querySelectorAll('*').forEach((el) => {
+ const attributes = el.getAttributeNames();
+ attributes.forEach((attr) => {
+ if (attr.includes('inkscape') || attr.includes('sodipodi')) el.removeAttribute(attr);
+ });
+ });
+
+ // remove all text if source is Noun project (to make it usable)
+ if (result.includes('from the Noun Project')) el.querySelectorAll('text').forEach((textEl) => textEl.remove());
+
+ const svg = el.querySelector('svg');
+ if (!svg) return tip("The file should be prepated for load to FMG. If you don't know why it's happening, try to upload the raster image", false, 'error');
+
+ const icon = uploadTo.appendChild(svg);
+ icon.id = id;
+ icon.setAttribute('width', 200);
+ icon.setAttribute('height', 200);
+ }
+
+ callback(type, id);
+ };
+
+ if (type === 'image') reader.readAsDataURL(file);
+ else reader.readAsText(file);
+ }
+
+ function changeColor(resource, line, el) {
+ const circle = el.querySelector('circle');
+
+ const callback = (fill) => {
+ const stroke = Resources.getStroke(fill);
+ circle.setAttribute('fill', fill);
+ circle.setAttribute('stroke', stroke);
+ resource.color = fill;
+ resource.stroke = stroke;
+ goods.selectAll(`circle[data-i='${resource.i}']`).attr('fill', fill).attr('stroke', stroke);
+ line.dataset.color = fill;
+ };
+
+ openPicker(resource.color, callback, {allowHatching: false});
+ }
+
+ function regenerateCurrentResources() {
+ const message = 'Are you sure you want to regenerate resources? This action cannot be reverted';
+ confirmationDialog({title: 'Regenerate resources', message, confirm: 'Regenerate', onConfirm: regenerateResources});
+ }
+
+ function resourcesRestoreDefaults() {
+ const message = 'Are you sure you want to restore default resources? This action cannot be reverted';
+ const onConfirm = () => {
+ delete pack.resources;
+ regenerateResources();
+ };
+ confirmationDialog({title: 'Restore default resources', message, confirm: 'Restore', onConfirm});
+ }
+
+ function toggleLegend() {
+ if (legend.selectAll('*').size()) {
+ clearLegend();
+ return;
+ }
+
+ const data = pack.resources
+ .filter((r) => r.i && r.cells)
+ .sort((a, b) => b.cells - a.cells)
+ .map((r) => [r.i, r.color, r.name]);
+ drawLegend('Resources', data);
+ }
+
+ function togglePercentageMode() {
+ if (body.dataset.type === "absolute") {
+ body.dataset.type = "percentage";
+ const totalCells = pack.cells.resource.filter((r) => r !== 0).length;
+
+ body.querySelectorAll(':scope > div').forEach(function (el) {
+ el.querySelector('.cells').innerHTML = rn((+el.dataset.cells / totalCells) * 100) + '%';
+ });
+ } else {
+ body.dataset.type = 'absolute';
+ resourcesEditorAddLines();
+ }
+ }
+
+ function enterResourceAssignMode() {
+ if (this.classList.contains('pressed')) return exitResourceAssignMode();
+ customization = 14;
+ this.classList.add('pressed');
+ if (!layerIsOn('toggleResources')) toggleResources();
+ if (!layerIsOn('toggleCells')) {
+ const toggler = byId('toggleCells');
+ toggler.dataset.forced = true;
+ toggleCells();
+ }
+
+ document
+ .getElementById('resourcesEditor')
+ .querySelectorAll('.hide')
+ .forEach((el) => el.classList.add('hidden'));
+ byId('resourcesFooter').style.display = 'none';
+ body.querySelectorAll('.resourceName, .resourceCategory, .resourceChance, .resourceCells, svg').forEach((e) => (e.style.pointerEvents = 'none'));
+ $('#resourcesEditor').dialog({position: {my: 'right top', at: 'right-10 top+10', of: 'svg', collision: 'fit'}});
+
+ tip('Select resource line in editor, click on cells to remove or add a resource', true);
+ viewbox.on('click', changeResourceOnCellClick);
+
+ body.querySelector('div').classList.add('selected');
+
+ const someArePinned = pack.resources.some((resource) => resource.pinned);
+ if (someArePinned) unpinAllResources();
+ }
+
+ function selectResourceOnLineClick() {
+ if (customization !== 14) return;
+ //if (this.parentNode.id !== "statesBodySection") return;
+ body.querySelector('div.selected').classList.remove('selected');
+ this.classList.add('selected');
+ }
+
+ function changeResourceOnCellClick() {
+ const point = d3.mouse(this);
+ const i = findCell(point[0], point[1]);
+ const selected = body.querySelector('div.selected');
+ if (!selected) return;
+
+ if (pack.cells.resource[i]) {
+ const resourceToRemove = Resources.get(pack.cells.resource[i]);
+ if (resourceToRemove) resourceToRemove.cells -= 1;
+ body.querySelector("div.states[data-id='" + resourceToRemove.i + "'] > .resourceCells").innerHTML = resourceToRemove.cells;
+ pack.cells.resource[i] = 0;
+ } else {
+ const resourceId = +selected.dataset.id;
+ const resource = Resources.get(resourceId);
+ resource.cells += 1;
+ body.querySelector("div.states[data-id='" + resourceId + "'] > .resourceCells").innerHTML = resource.cells;
+ pack.cells.resource[i] = resourceId;
+ }
+
+ goods.selectAll('*').remove();
+ drawResources();
+ }
+
+ function exitResourceAssignMode(close) {
+ customization = 0;
+ byId('resourcesAssign').classList.remove('pressed');
+
+ if (layerIsOn('toggleCells')) {
+ const toggler = byId('toggleCells');
+ if (toggler.dataset.forced) toggleCells();
+ delete toggler.dataset.forced;
+ }
+
+ document
+ .getElementById('resourcesEditor')
+ .querySelectorAll('.hide')
+ .forEach((el) => el.classList.remove('hidden'));
+ byId('resourcesFooter').style.display = 'block';
+ body.querySelectorAll('.resourceName, .resourceCategory, .resourceChance, .resourceCells, svg').forEach((e) => delete e.style.pointerEvents);
+ !close && $('#resourcesEditor').dialog({position: {my: 'right top', at: 'right-10 top+10', of: 'svg', collision: 'fit'}});
+
+ restoreDefaultEvents();
+ clearMainTip();
+ const selected = body.querySelector('div.selected');
+ if (selected) selected.classList.remove('selected');
+ }
+
+ function resourceAdd() {
+ if (pack.resources.length >= 256) return tip('Maximum number of resources is reached', false, 'error');
+
+ let i = last(pack.resources).i;
+ while (Resources.get(i)) {
+ i++;
+ }
+ const resource = {i, name: 'Resource' + i, category: 'Unknown', icon: 'resource-unknown', color: '#ff5959', value: 1, chance: 10, model: 'habitability', bonus: {population: 1}, cells: 0};
+ pack.resources.push(resource);
+ tip('Resource is added', false, 'success', 3000);
+ resourcesEditorAddLines();
+ }
+
+ function downloadResourcesData() {
+ let data = 'Id,Resource,Color,Category,Value,Bonus,Chance,Model,Cells\n'; // headers
+
+ body.querySelectorAll(':scope > div').forEach(function (el) {
+ data += el.dataset.id + ',';
+ data += el.dataset.name + ',';
+ data += el.dataset.color + ',';
+ data += el.dataset.category + ',';
+ data += el.dataset.value + ',';
+ data += el.dataset.bonus + ',';
+ data += el.dataset.chance + ',';
+ data += el.dataset.model + ',';
+ data += el.dataset.cells + '\n';
+ });
+
+ const name = getFileName('Resources') + '.csv';
+ downloadFile(data, name);
+ }
+
+ function pinResource(resource, el) {
+ const pin = el.classList.contains('inactive');
+ el.classList.toggle('inactive');
+
+ if (pin) resource.pinned = pin;
+ else delete resource.pinned;
+
+ goods.selectAll('*').remove();
+ drawResources();
+
+ // manage top unpin all button state
+ const someArePinned = pack.resources.some((resource) => resource.pinned);
+ const unpinAll = byId('resourcesUnpinAll');
+ someArePinned ? unpinAll.classList.remove('hidden') : unpinAll.classList.add('hidden');
+ }
+
+ function unpinAllResources() {
+ pack.resources.forEach((resource) => delete resource.pinned);
+ goods.selectAll('*').remove();
+ drawResources();
+
+ byId('resourcesUnpinAll').classList.add('hidden');
+ body.querySelectorAll(':scope > div > span.icon-pin').forEach((el) => el.classList.add('inactive'));
+ }
+
+ function removeResource(res, line) {
+ if (customization) return;
+
+ const message = 'Are you sure you want to remove the resource? This action cannot be reverted';
+ const onConfirm = () => {
+ for (const i of pack.cells.i) {
+ if (pack.cells.resource[i] === res.i) {
+ pack.cells.resource[i] = 0;
+ }
+ }
+
+ pack.resources = pack.resources.filter((resource) => resource.i !== res.i);
+ line.remove();
+
+ goods.selectAll('*').remove();
+ drawResources();
+ };
+ confirmationDialog({title: "Remove resource", message, confirm: "Remove", onConfirm});
+ }
+
+ function closeResourcesEditor() {
+ if (customization === 14) exitResourceAssignMode("close");
+ unpinAllResources();
+ body.innerHTML = "";
+ }
+}
diff --git a/modules/ui/style.js b/modules/ui/style.js
index 3df790264..aaa065e85 100644
--- a/modules/ui/style.js
+++ b/modules/ui/style.js
@@ -351,6 +351,13 @@ function selectStyleElement() {
emblemsBurgSizeInput.value = emblems.select("#burgEmblems").attr("data-size") || 1;
}
+ if (styleElement === "goods") {
+ styleStrokeWidth.style.display = "block";
+ styleStrokeWidthInput.value = el.attr("stroke-width") || "";
+ styleResources.style.display = "block";
+ styleResourcesCircle.checked = +el.attr("data-circle");
+ }
+
// update group options
styleGroupSelect.options.length = 0; // remove all options
if (["routes", "labels", "coastline", "lakes", "anchors", "burgIcons", "borders", "terrs"].includes(styleElement)) {
@@ -976,6 +983,12 @@ emblemsBurgSizeInput.on("change", e => {
drawEmblems();
});
+styleResourcesCircle.on("change", e => {
+ goods.attr("data-circle", +this.checked);
+ goods.selectAll("*").remove();
+ drawResources();
+})
+
// request a URL to image to be used as a texture
function textureProvideURL() {
alertMessage.innerHTML = /* html */ `Provide a texture image URL:
diff --git a/modules/ui/tools.js b/modules/ui/tools.js
index 5c67ca0f0..d37c5eb02 100644
--- a/modules/ui/tools.js
+++ b/modules/ui/tools.js
@@ -14,6 +14,7 @@ toolsContent.addEventListener("click", function (event) {
else if (button === "editProvincesButton") editProvinces();
else if (button === "editDiplomacyButton") editDiplomacy();
else if (button === "editCulturesButton") editCultures();
+ else if (button === "editResourcesButton") editResources();
else if (button === "editReligions") editReligions();
else if (button === "editEmblemButton") openEmblemEditor();
else if (button === "editNamesBaseButton") editNamesbase();
@@ -90,6 +91,7 @@ function processFeatureRegeneration(event, button) {
else if (button === "regenerateProvinces") regenerateProvinces();
else if (button === "regenerateBurgs") regenerateBurgs();
else if (button === "regenerateEmblems") regenerateEmblems();
+ else if (button === "regenerateResources") regenerateResources();
else if (button === "regenerateReligions") regenerateReligions();
else if (button === "regenerateCultures") regenerateCultures();
else if (button === "regenerateMilitary") regenerateMilitary();
@@ -126,6 +128,13 @@ function regenerateRoutes() {
if (layerIsOn("toggleRoutes")) drawRoutes();
}
+function regenerateResources() {
+ Resources.generate();
+ goods.selectAll("*").remove();
+ if (layerIsOn("toggleResources")) drawResources();
+ refreshAllEditors();
+}
+
function regenerateRivers() {
Rivers.generate();
Rivers.specify();
|