@@ -1957,6 +1976,9 @@
+
@@ -2030,6 +2052,12 @@
>
Burgs
+
@@ -4908,6 +4936,61 @@
>
JPG
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Upload:
+
+
+
+
+
+
+
+
@@ -7778,6 +7861,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -7810,7 +8101,10 @@
+
+
+
diff --git a/main.js b/main.js
index 36a59cae..d3231ff2 100644
--- a/main.js
+++ b/main.js
@@ -715,12 +715,27 @@ async function generate(options) {
Lakes.defineGroup();
defineBiomes();
+ Resources.generate();
+
rankCells();
Cultures.generate();
Cultures.expand();
BurgsAndStates.generate();
Religions.generate();
BurgsAndStates.defineStateForms();
+ BurgsAndStates.defineTaxes();
+
+ Production.collectResources();
+
+ Trade.defineCenters();
+ Trade.calculateDistances();
+ Trade.exportGoods();
+ Trade.importGoods();
+
+ // temp, replace with route generator
+ // pack.cells.road = new Uint16Array(pack.cells.i.length);
+ // pack.cells.crossroad = new Uint16Array(pack.cells.i.length);
+
BurgsAndStates.generateProvinces();
BurgsAndStates.defineBurgFeatures();
diff --git a/modules/burgs-and-states.js b/modules/burgs-and-states.js
index cff34d46..bfa84e44 100644
--- a/modules/burgs-and-states.js
+++ b/modules/burgs-and-states.js
@@ -1142,6 +1142,29 @@ window.BurgsAndStates = (function () {
return adjName ? `${getAdjective(s.name)} ${s.formName}` : `${s.formName} of ${s.name}`;
};
+ const defineTaxes = function () {
+ const {states} = pack;
+ const maxTaxPerForm = {
+ Monarchy: 0.3,
+ Republic: 0.1,
+ Union: 0.2,
+ Theocracy: 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;
+ }
+ };
+
const generateProvinces = function (regenerate) {
TIME && console.time("generateProvinces");
const localSeed = regenerate ? generateSeed() : seed;
@@ -1372,6 +1395,7 @@ window.BurgsAndStates = (function () {
generateDiplomacy,
defineStateForms,
getFullName,
+ defineTaxes,
generateProvinces,
updateCultures
};
diff --git a/modules/production-generator.js b/modules/production-generator.js
new file mode 100644
index 00000000..56da2fa1
--- /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 PriorityQueue({comparator: (a, b) => b.priority - a.priority});
+ 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.queue({resourceId: +resourceId, basePriority, priority, production, isFood});
+ }
+
+ 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.dequeue();
+ 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.queue({...occupation, basePriority: newBasePriority, priority: newPriority});
+ }
+
+ burg.produced = {};
+ for (const resourceId in productionPull) {
+ const production = productionPull[resourceId];
+ burg.produced[resourceId] = Math.ceil(production);
+ }
+ }
+ };
+
+ return {collectResources};
+})();
diff --git a/modules/resources-generator.js b/modules/resources-generator.js
new file mode 100644
index 00000000..852103a0
--- /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 00000000..0615a920
--- /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 ea3a76f2..bc22bdae 100644
--- a/modules/ui/burg-editor.js
+++ b/modules/ui/burg-editor.js
@@ -94,6 +94,12 @@ function editBurg(id) {
if (b.shanty) document.getElementById("burgShanty").classList.remove("inactive");
else document.getElementById("burgShanty").classList.add("inactive");
+ // economics block
+ document.getElementById('burgProduction').innerHTML = getProduction(b.produced);
+ const deals = pack.trade.deals;
+ document.getElementById('burgExport').innerHTML = getExport(deals.filter((deal) => deal.exporter === b.i));
+ document.getElementById('burgImport').innerHTML = '';
+
//toggle lock
updateBurgLockIcon();
@@ -126,6 +132,41 @@ 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 += `
+
+ ${production}
+ `;
+ }
+
+ 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('')}`;
+ }
+
// in °C, array from -1 °C; source: https://en.wikipedia.org/wiki/List_of_cities_by_average_temperature
function getTemperatureLikeness(temperature) {
if (temperature < -5) return "Yakutsk";
diff --git a/modules/ui/general.js b/modules/ui/general.js
index fa030418..4305f016 100644
--- a/modules/ui/general.js
+++ b/modules/ui/general.js
@@ -136,7 +136,12 @@ function showMapTooltip(point, e, i, g) {
tip(`${name} ${type} emblem. Click to edit. Hold Shift to show associated area or place`);
return;
}
-
+ if (group === 'goods') {
+ const id = +e.target.dataset.i;
+ const resource = pack.resources.find((resource) => resource.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/layers.js b/modules/ui/layers.js
index 304f455a..c9c2d5fd 100644
--- a/modules/ui/layers.js
+++ b/modules/ui/layers.js
@@ -48,6 +48,17 @@ function getDefaultPresets() {
"toggleRoutes",
"toggleScaleBar"
],
+ economical: [
+ "toggleResources",
+ "toggleBiomes",
+ "toggleBorders",
+ "toggleIcons",
+ "toggleIce",
+ "toggleLabels",
+ "toggleRivers",
+ "toggleRoutes",
+ "toggleScaleBar"
+ ],
military: [
"toggleBorders",
"toggleIcons",
@@ -1867,7 +1878,50 @@ function drawEmblems() {
invokeActiveZooming();
});
- TIME && console.timeEnd("drawEmblems");
+ TIME && console.timeEnd('drawEmblems');
+}
+
+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 layerIsOn(el) {
@@ -1917,6 +1971,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 === "toggleIcons") return $("#icons");
diff --git a/modules/ui/resource-editor.js b/modules/ui/resource-editor.js
new file mode 100644
index 00000000..759a83e7
--- /dev/null
+++ b/modules/ui/resource-editor.js
@@ -0,0 +1,645 @@
+'use strict';
+function editResources() {
+ if (customization) return;
+ closeDialogs('#resourcesEditor, .stable');
+ if (!layerIsOn('toggleResources')) toggleResources();
+ const body = document.getElementById('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
+ document.getElementById('resourcesEditorRefresh').addEventListener('click', resourcesEditorAddLines);
+ document.getElementById('resourcesRegenerate').addEventListener('click', regenerateCurrentResources);
+ document.getElementById('resourcesLegend').addEventListener('click', toggleLegend);
+ document.getElementById('resourcesPercentage').addEventListener('click', togglePercentageMode);
+ document.getElementById('resourcesAssign').addEventListener('click', enterResourceAssignMode);
+ document.getElementById('resourcesAdd').addEventListener('click', resourceAdd);
+ document.getElementById('resourcesRestore').addEventListener('click', resourcesRestoreDefaults);
+ document.getElementById('resourcesExport').addEventListener('click', downloadResourcesData);
+ document.getElementById('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
+ document.getElementById('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 = document.getElementById('resouceCategoryAdd').value;
+ const select = document.getElementById('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 = document.getElementById('resouceModelCustomName').value;
+ const customFn = document.getElementById('resouceModelCustomFunction').value;
+
+ const message = document.getElementById('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 = document.getElementById('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 = document.getElementById('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(document.getElementById('resource-icons').querySelectorAll('symbol')).map((el) => el.id);
+ const standardIconsOptions = standardIcons.map((icon) => ``);
+
+ const customIconsEl = document.getElementById('defs-icons');
+ const customIcons = customIconsEl ? Array.from(document.getElementById('defs-icons').querySelectorAll('svg')).map((el) => el.id) : [];
+ const customIconsOptions = customIcons.map((icon) => ``);
+
+ const select = document.getElementById('resourceSelectIcon');
+ select.innerHTML = standardIconsOptions + customIconsOptions;
+ select.value = resource.icon;
+
+ const preview = document.getElementById('resourceIconPreview');
+ preview.setAttribute('href', '#' + resource.icon);
+
+ const viewBoxSection = document.getElementById('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 = document.getElementById('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 = document.getElementById(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);
+ document.getElementById('resourceUploadIconRaster').onclick = () => imageToLoad.click();
+ document.getElementById('resourceUploadIconVector').onclick = () => svgToLoad.click();
+ document.getElementById('imageToLoad').onchange = () => uploadImage('image', uploadTo, onUpload);
+ document.getElementById('svgToLoad').onchange = () => uploadImage('svg', uploadTo, onUpload);
+ }
+
+ function uploadImage(type, uploadTo, callback) {
+ const input = type === 'image' ? document.getElementById('imageToLoad') : document.getElementById('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 = document.getElementById('toggleCells');
+ toggler.dataset.forced = true;
+ toggleCells();
+ }
+
+ document
+ .getElementById('resourcesEditor')
+ .querySelectorAll('.hide')
+ .forEach((el) => el.classList.add('hidden'));
+ document.getElementById('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;
+ document.getElementById('resourcesAssign').classList.remove('pressed');
+
+ if (layerIsOn('toggleCells')) {
+ const toggler = document.getElementById('toggleCells');
+ if (toggler.dataset.forced) toggleCells();
+ delete toggler.dataset.forced;
+ }
+
+ document
+ .getElementById('resourcesEditor')
+ .querySelectorAll('.hide')
+ .forEach((el) => el.classList.remove('hidden'));
+ document.getElementById('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 = document.getElementById('resourcesUnpinAll');
+ someArePinned ? unpinAll.classList.remove('hidden') : unpinAll.classList.add('hidden');
+ }
+
+ function unpinAllResources() {
+ pack.resources.forEach((resource) => delete resource.pinned);
+ goods.selectAll('*').remove();
+ drawResources();
+
+ document.getElementById('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 b0f092fc..eec6ff33 100644
--- a/modules/ui/style.js
+++ b/modules/ui/style.js
@@ -263,6 +263,13 @@ function selectStyleElement() {
styleStrokeWidthInput.value = styleStrokeWidthOutput.value = el.attr("stroke-width") || 1;
}
+ if (sel === 'goods') {
+ styleStrokeWidth.style.display = 'block';
+ styleStrokeWidthInput.value = styleStrokeWidthOutput.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"].includes(sel)) {
@@ -714,6 +721,12 @@ emblemsStateSizeInput.addEventListener("change", drawEmblems);
emblemsProvinceSizeInput.addEventListener("change", drawEmblems);
emblemsBurgSizeInput.addEventListener("change", drawEmblems);
+styleResourcesCircle.addEventListener('change', function () {
+ 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 an image URL to be used as a texture:
diff --git a/modules/ui/tools.js b/modules/ui/tools.js
index 0c0b9130..afc38d71 100644
--- a/modules/ui/tools.js
+++ b/modules/ui/tools.js
@@ -14,6 +14,7 @@ toolsContent.addEventListener("click", function (event) {
else if (button === "editDiplomacyButton") editDiplomacy();
else if (button === "editCulturesButton") editCultures();
else if (button === "editReligions") editReligions();
+ else if (button === 'editResources') editResources();
else if (button === "editEmblemButton") openEmblemEditor();
else if (button === "editNamesBaseButton") editNamesbase();
else if (button === "editUnitsButton") editUnits();
@@ -87,6 +88,7 @@ function processFeatureRegeneration(event, button) {
else if (button === "regenerateStates") regenerateStates();
else if (button === "regenerateProvinces") regenerateProvinces();
else if (button === "regenerateBurgs") regenerateBurgs();
+ else if (button === 'regenerateResources') regenerateResources();
else if (button === "regenerateEmblems") regenerateEmblems();
else if (button === "regenerateReligions") regenerateReligions();
else if (button === "regenerateCultures") regenerateCultures();
@@ -347,6 +349,13 @@ function regenerateBurgs() {
if (document.getElementById("statesEditorRefresh")?.offsetParent) statesEditorRefresh.click();
}
+function regenerateResources() {
+ Resources.generate();
+ goods.selectAll('*').remove();
+ if (layerIsOn('toggleResources')) drawResources();
+ refreshAllEditors();
+}
+
function regenerateEmblems() {
// remove old emblems
document.querySelectorAll("[id^=stateCOA]").forEach(el => el.remove());
|