diff --git a/index.css b/index.css index 1cb69877..db872a5f 100644 --- a/index.css +++ b/index.css @@ -1529,10 +1529,47 @@ div.states > .riverType { pointer-events: none; } +div.states > .resourceIcon { + margin: 0; + cursor: pointer; + vertical-align: middle; +} + +div.states > .resourceIcon > * { + pointer-events: none; +} + #diplomacyBodySection > div { cursor: pointer; } +div.states > .resourceCategory, +div.states > .resourceModel { + cursor: pointer; + width: 7em; + overflow: hidden; + vertical-align: middle; + white-space: nowrap; +} + +div.states > .resourceBonus { + color: #666; + width: 5em; + cursor: pointer; +} + +div.states > div.resourceBonus > span { + pointer-events: none; + margin: 0 0.15em; +} +div.states > div.resourceBonus > span.icon-anchor { + font-size: 0.85em; + margin: 0 0.25em; +} +div.states > div.resourceBonus > span.icon-male { + margin: 0 0.1em; +} + .changeRelations > * { pointer-events: none; cursor: pointer; @@ -1570,6 +1607,12 @@ div.states > .riverType { width: 6em; } +#burgBody svg.resIcon { + vertical-align: top; + width: 1.6em; + height: 1.6em; +} + #burgBody > div > div, #riverBody > div, #lakeBody > div { @@ -1589,6 +1632,7 @@ div.states > .riverType { #stateNameEditor div.label, #provinceNameEditor div.label, #regimentBody div.label, +#resourceIconEditor div.label, #markerEditor div.label { display: inline-block; width: 5.5em; @@ -2243,6 +2287,9 @@ svg.button { user-select: none; } +#goods > g > use { + pointer-events: none; +} .dontAsk { margin: 0.9em 0 0 0.6em; diff --git a/index.html b/index.html index 946c53d1..574567d7 100644 --- a/index.html +++ b/index.html @@ -427,6 +427,7 @@ + @@ -630,6 +631,14 @@ > Precipitation +
  • + Resources +
  • Provinces + @@ -1414,6 +1424,15 @@ + + + + + + + + + @@ -1957,6 +1976,9 @@ + @@ -2030,6 +2052,12 @@ > Burgs + @@ -4908,6 +4936,61 @@ > JPG + + + + + @@ -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:
    + +
    + +
    +
    Custom 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 = ` +
    + Predefined models +
    +
    Name:
    + +
    + +
    +
    Function:
    +
    + ${models[model] || ' '} +
    +
    +
    + +
    + Custom model +
    +
    Name:
    + +
    + +
    +
    Function:
    + +
    +
    + +
    + `; + + $('#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());