This commit is contained in:
Peter Sofronas 2025-01-15 02:12:00 +00:00 committed by GitHub
commit bd43df2e46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2190 additions and 43 deletions

View file

@ -1538,6 +1538,45 @@ div.states > .riverType {
pointer-events: none;
}
div.states > .resourceIcon {
margin: 0;
cursor: pointer;
vertical-align: middle;
}
div.states > .resourceIcon > * {
pointer-events: none;
}
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;
}
#diplomacyBodySection > div {
cursor: pointer;
}
@ -1579,6 +1618,13 @@ div.states > .riverType {
width: 6em;
}
#burgBody .resIcon{
width: 1.5em;
height: 1.5em;
vertical-align: middle;
margin-right: 0.2em;
}
#burgBody > div > div,
#riverBody > div,
#routeBody > div,
@ -1606,6 +1652,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;
@ -2232,6 +2279,10 @@ svg.button {
user-select: none;
}
#goods > g > use {
pointer-events: none;
}
.dontAsk {
margin: 0.9em 0 0 0.6em;
display: inline-flex;
@ -2419,4 +2470,4 @@ svg.button {
body {
background: #25252a;
}
}
}

File diff suppressed because one or more lines are too long

19
main.js
View file

@ -77,6 +77,7 @@ let ice = viewbox.append("g").attr("id", "ice");
let prec = viewbox.append("g").attr("id", "prec").style("display", "none");
let population = viewbox.append("g").attr("id", "population");
let emblems = viewbox.append("g").attr("id", "emblems").style("display", "none");
let goods = viewbox.append("g").attr("id", "goods");
let labels = viewbox.append("g").attr("id", "labels");
let icons = viewbox.append("g").attr("id", "icons");
let burgIcons = icons.append("g").attr("id", "burgIcons");
@ -647,6 +648,8 @@ async function generate(options) {
Rivers.generate();
Biomes.define();
Resources.generate();
rankCells();
Cultures.generate();
Cultures.expand();
@ -657,6 +660,14 @@ async function generate(options) {
Provinces.generate();
Provinces.getPoles();
BurgsAndStates.defineBurgFeatures();
BurgsAndStates.defineTaxes();
Production.collectResources();
Trade.defineCenters();
Trade.calculateDistances();
Trade.exportGoods();
Trade.importGoods();
Rivers.specify();
Features.specify();
@ -1172,6 +1183,7 @@ function rankCells() {
const flMean = d3.median(cells.fl.filter(f => f)) || 0;
const flMax = d3.max(cells.fl) + d3.max(cells.conf); // to normalize flux
const areaMean = d3.mean(cells.area); // to adjust population by cell area
const getResValue = (i) => (cells.resource[i] ? Resources.get(cells.resource[i])?.value : 0); // get bonus resource scope
for (const i of cells.i) {
if (cells.h[i] < 20) continue; // no population in water
@ -1196,7 +1208,12 @@ function rankCells() {
}
}
cells.s[i] = s / 5; // general population rate
// add bonus for resource around
const cellRes = getResValue(i);
const neibRes = d3.mean(cells.c[i].map((c) => getResValue(c)));
const resBonus = (cellRes ? cellRes + 10 : 0) + neibRes;
cells.s[i] = s / 5 + resBonus; // general population rate
// cell rural population is suitability adjusted by cell area
cells.pop[i] = cells.s[i] > 0 ? (cells.s[i] * cells.area[i]) / areaMean : 0;
}

View file

@ -865,6 +865,29 @@ window.BurgsAndStates = (() => {
return adjName ? `${getAdjective(state.name)} ${state.formName}` : `${state.formName} of ${state.name}`;
};
const defineTaxes = () => {
const {states} = pack;
const maxTaxPerForm = {
Monarchy: 0.3,
Republic: 0.1,
Union: 0.2,
Thearchy: 0.3,
Anarchy: 0
};
for (const state of states) {
const {i, removed, form} = state;
if (removed) continue;
if (!i) {
state.salesTax = 0;
continue;
}
const maxTax = maxTaxPerForm[form] || 0;
state.salesTax = maxTax ? rn(Math.random() * maxTax, 2) : 0;
}
};
return {
generate,
expandStates,
@ -881,6 +904,7 @@ window.BurgsAndStates = (() => {
defineStateForms,
getFullName,
updateCultures,
getCloseToEdgePoint
getCloseToEdgePoint,
defineTaxes
};
})();
})();

View file

@ -343,6 +343,7 @@ async function parseLoadedData(data, mapVersion) {
coastline = viewbox.select("#coastline");
prec = viewbox.select("#prec");
population = viewbox.select("#population");
resources = viewbox.select("#goods")
emblems = viewbox.select("#emblems");
labels = viewbox.select("#labels");
icons = viewbox.select("#icons");
@ -405,6 +406,7 @@ async function parseLoadedData(data, mapVersion) {
pack.cells.province = data[27] ? Uint16Array.from(data[27].split(",")) : new Uint16Array(pack.cells.i.length);
// data[28] had deprecated cells.crossroad
pack.cells.routes = data[36] ? JSON.parse(data[36]) : {};
pack.resources = data[40] ? JSON.parse(data[40]) : {};
if (data[31]) {
const namesDL = data[31].split("/");
@ -553,6 +555,14 @@ async function parseLoadedData(data, mapVersion) {
ERROR && console.error("[Data integrity] Invalid river", r, "is assigned to cells", invalidCells);
});
const invalidResources = [...new Set(cells.r)].filter(r => r && !pack.resources.find(resource => resource.i === r));
invalidResources.forEach(r=> {
const invalidCells = cells.i.filter(i => cells.r[i] === r);
invalidCells.forEach(i => (cells.r[i] = 0));
rivers.select("resource" + r).remove();
ERROR && console.error("Data integrity check. Invalid resource", r, "is assigned to cells", invalidCells);
})
pack.burgs.forEach(burg => {
if (typeof burg.capital === "boolean") burg.capital = Number(burg.capital);

View file

@ -97,6 +97,7 @@ function prepareMapData() {
const religions = JSON.stringify(pack.religions);
const provinces = JSON.stringify(pack.provinces);
const rivers = JSON.stringify(pack.rivers);
const resource = JSON.stringify(pack.resources);
const markers = JSON.stringify(pack.markers);
const cellRoutes = JSON.stringify(pack.cells.routes);
const routes = JSON.stringify(pack.routes);
@ -116,45 +117,47 @@ function prepareMapData() {
// data format as below
const mapData = [
params,
settings,
coords,
biomes,
notesData,
serializedSVG,
gridGeneral,
grid.cells.h,
grid.cells.prec,
grid.cells.f,
grid.cells.t,
grid.cells.temp,
packFeatures,
cultures,
states,
burgs,
pack.cells.biome,
pack.cells.burg,
pack.cells.conf,
pack.cells.culture,
pack.cells.fl,
pop,
pack.cells.r,
[], // deprecated pack.cells.road
pack.cells.s,
pack.cells.state,
pack.cells.religion,
pack.cells.province,
[], // deprecated pack.cells.crossroad
religions,
provinces,
namesData,
rivers,
rulersString,
fonts,
markers,
cellRoutes,
routes,
zones
params, //1
settings, //2
coords, //3
biomes, //4
notesData, //5
serializedSVG, //6
gridGeneral, //7
grid.cells.h, //8
grid.cells.prec, //9
grid.cells.f, //10
grid.cells.t, //11
grid.cells.temp, //12
packFeatures, //13
cultures, //14
states, //15
burgs, //16
pack.cells.biome, //17
pack.cells.burg, //18
pack.cells.conf, //19
pack.cells.culture, //20
pack.cells.fl, //21
pop, //22
pack.cells.r, //23
[], // deprecated pack.cells.road 24
pack.cells.s, // 25
pack.cells.state, //26
pack.cells.religion, //27
pack.cells.province, //28
[], // deprecated pack.cells.crossroad 29
religions, //30
provinces, //31
namesData, //32
rivers, //33
rulersString, //34
fonts, //35
markers, //36
cellRoutes, //37
routes, //38
zones, //39
pack.cells.resources, //40
resources //41
].join("\r\n");
return mapData;
}

View file

@ -0,0 +1,114 @@
'use strict';
window.Production = (function () {
const BONUS_PRODUCTION = 4;
const BIOME_PRODUCTION = [
[{resource: 11, production: 0.75}], // marine: fish
[{resource: 2, production: 0.5}], // hot desert: stone
[{resource: 2, production: 0.5}], // cold desert: stone
[
{resource: 12, production: 0.4},
{resource: 10, production: 0.4}
], // savanna: game 0.75, cattle 0.75
[{resource: 10, production: 0.5}], // grassland: cattle
[{resource: 9, production: 0.5}], // tropical seasonal forest: grain
[
{resource: 9, production: 0.5},
{resource: 1, production: 0.5}
], // temperate deciduous forest: grain, wood
[
{resource: 9, production: 0.5},
{resource: 1, production: 0.5}
], // tropical rainforest: grain, wood
[
{resource: 9, production: 0.5},
{resource: 1, production: 0.5}
], // temperate rainforest: grain, wood
[
{resource: 1, production: 0.5},
{resource: 12, production: 0.4}
], // taiga: wood, game
[{resource: 29, production: 0.5}], // tundra: furs
[], // glacier: nothing
[
{resource: 4, production: 0.4},
{resource: 12, production: 0.4}
] // wetland: iron, game
];
const RIVER_PRODUCTION = [{resource: 11, production: 0.5}]; // fish
const HILLS_PRODUCTION = [{resource: 34, production: 0.5}]; // coal
const FOOD_MULTIPLIER = 3;
const collectResources = () => {
const {cells, burgs} = pack;
for (const burg of burgs) {
if (!burg.i || burg.removed) continue;
const {cell, type, population} = burg;
const resourcesPull = {};
const addResource = (resourceId, production) => {
const currentProd = resourcesPull[resourceId] || 0;
if (!currentProd) {
resourcesPull[resourceId] = production;
} else {
if (production > currentProd) resourcesPull[resourceId] = production + currentProd / 3;
else resourcesPull[resourceId] = currentProd + production / 3;
}
};
const cellsInArea = cells.c[cell].concat([cell]);
for (const cell of cellsInArea) {
cells.resource[cell] && addResource(cells.resource[cell], BONUS_PRODUCTION);
BIOME_PRODUCTION[cells.biome[cell]].forEach(({resource, production}) => addResource(resource, production));
cells.r[cell] && RIVER_PRODUCTION.forEach(({resource, production}) => addResource(resource, production));
cells.h[cell] >= 50 && HILLS_PRODUCTION.forEach(({resource, production}) => addResource(resource, production));
}
const queue = new FlatQueue();
for (const resourceId in resourcesPull) {
const baseProduction = resourcesPull[resourceId];
const resource = Resources.get(+resourceId);
const cultureModifier = resource.culture[type] || 1;
const production = baseProduction * cultureModifier;
const {value, category} = resource;
const isFood = category === 'Food';
const basePriority = production * value;
const priority = basePriority * (isFood ? FOOD_MULTIPLIER : 1);
queue.push({resourceId: +resourceId, basePriority, priority, production, isFood},0);
}
let foodProduced = 0;
const productionPull = {};
const addProduction = (resourceId, production) => {
if (!productionPull[resourceId]) productionPull[resourceId] = production;
else productionPull[resourceId] += production;
};
for (let i = 0; i < population; i++) {
const occupation = queue.pop();
const {resourceId, production, basePriority, isFood} = occupation;
addProduction(resourceId, production);
if (isFood) foodProduced += production;
const foodModifier = isFood && foodProduced < population ? FOOD_MULTIPLIER : 1;
const newBasePriority = basePriority / 2;
const newPriority = newBasePriority * foodModifier;
queue.push({...occupation, basePriority: newBasePriority, priority: newPriority},0);
}
burg.produced = {};
for (const resourceId in productionPull) {
const production = productionPull[resourceId];
burg.produced[resourceId] = Math.ceil(production);
}
}
};
return {collectResources};
})();

View file

@ -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};
})();

239
modules/trade-generator.js Normal file
View file

@ -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};
})();

View file

@ -95,6 +95,12 @@ function editBurg(id) {
if (b.shanty) byId("burgShanty").classList.remove("inactive");
else byId("burgShanty").classList.add("inactive");
// economics block
byId("burgProduction").innerHTML = getProduction(b.produced);
const deals = pack.trade.deals;
byId("burgExport").innerHTML = getExport(deals.filter((deal) => deal.exporter === b.i));
byId("burgImport").innerHTML = "";
//toggle lock
updateBurgLockIcon();
@ -120,6 +126,38 @@ function editBurg(id) {
}
}
function getProduction(pool) {
let html = "";
for (const resourceId in pool) {
const {name, unit, icon} = Resources.get(+resourceId);
const production = pool[resourceId];
const unitName = production > 1 ? unit + 's' : unit;
html += `<span data-tip="${name}: ${production} ${unitName}">
<svg class="resIcon"><use href="#{$icon}"></svg>
</span>`;
}
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 `<span data-tip="${name}: ${quantity} ${unitName}. Income: ${rn(burgIncome)}">
<svg class="resIcon"><use href="#${icon}"></svg>
<span style="margin: 0 0.2em 0 -0.2em">${quantity}</span>
</span>`;
});
return `${totalIncome}: ${exported.join('')}`;
}
function dragBurgLabel() {
const tr = parseTransform(this.getAttribute("transform"));
const dx = +tr[0] - d3.event.x,

View file

@ -142,6 +142,14 @@ function showMapTooltip(point, e, i, g) {
return;
}
if (group === "goods") {
const id = +e.target.dataset.i;
const resource = pack.resources.find(res => res.i === id);
tip("Resource: " + resource.name);
return;
}
if (group === "rivers") {
const river = +e.target.id.slice(5);
const r = pack.rivers.find(r => r.i === river);

View file

@ -238,6 +238,9 @@ function editHeightmap(options) {
}
Biomes.define();
Resources.generate();
rankCells();
Cultures.generate();

View file

@ -74,6 +74,7 @@ function handleKeyup(event) {
else if (code === "KeyZ") toggleZones();
else if (code === "KeyD") toggleBorders();
else if (code === "KeyR") toggleReligions();
else if (code === "KeyQ") toggleResources();
else if (code === "KeyU") toggleRoutes();
else if (code === "KeyT") toggleTemperature();
else if (code === "KeyN") togglePopulation();

View file

@ -59,6 +59,7 @@ function getDefaultPresets() {
"toggleScaleBar",
"toggleVignette"
],
economical: ['toggleResources', 'toggleBiomes', 'toggleBorders', 'toggleIcons', 'toggleIce', 'toggleLabels', 'toggleRivers', 'toggleRoutes', 'toggleScaleBar'],
military: [
"toggleBorders",
"toggleBurgIcons",
@ -968,6 +969,49 @@ function toggleEmblems(event) {
}
}
function toggleResources(event) {
if (!layerIsOn('toggleResources')) {
turnButtonOn('toggleResources');
drawResources();
if (event && isCtrlClick(event)) editStyle('goods');
} else {
if (event && isCtrlClick(event)) {
editStyle('goods');
return;
}
goods.selectAll('*').remove();
turnButtonOff('toggleResources');
}
}
function drawResources() {
console.time('drawResources');
const someArePinned = pack.resources.some((resource) => resource.pinned);
// const drawCircle = +goods.attr('data-circle');
let resourcesHTML = '';
for (const i of pack.cells.i) {
if (!pack.cells.resource[i]) continue;
const resource = Resources.get(pack.cells.resource[i]);
if (someArePinned && !resource.pinned) continue;
const [x, y] = pack.cells.p[i];
const stroke = Resources.getStroke(resource.color);
// if (!drawCircle) {
// resourcesHTML += `<use data-i="${resource.i}" href="#${resource.icon}" x="${x - 3}" y="${y - 3}" width="6" height="6"/>`;
// continue;
// }
resourcesHTML += `<svg>
<circle data-i="${resource.i}" cx=${x} cy=${y} r="3" fill="${resource.color}" stroke="${stroke}" />
<use href="#${resource.icon}" x="${x - 3}" y="${y - 3}" width="6" height="6"/>
</svg>`;
}
goods.html(resourcesHTML);
console.timeEnd('drawResources');
}
function toggleVignette(event) {
if (!layerIsOn("toggleVignette")) {
turnButtonOn("toggleVignette");
@ -1034,6 +1078,7 @@ function getLayer(id) {
if (id === "togglePopulation") return $("#population");
if (id === "toggleIce") return $("#ice");
if (id === "toggleTexture") return $("#texture");
if (id === "toggleResources") return $("#goods")
if (id === "toggleEmblems") return $("#emblems");
if (id === "toggleLabels") return $("#labels");
if (id === "toggleBurgIcons") return $("#icons");

View file

@ -0,0 +1,646 @@
"use strict";
function editResources() {
if (customization) return;
closeDialogs("#resourcesEditor, .stable");
if (!layerIsOn("toggleResources")) toggleResources();
const body = byId("resourcesBody");
resourcesEditorAddLines();
if (modules.editResources) return;
modules.editResources = true;
$("#resourcesEditor").dialog({
title: "Resources Editor",
resizable: false,
width: fitContent(),
close: closeResourcesEditor,
position: {my: "right top", at: "right-10 top+10", of: "svg"}
});
// add listeners
byId("resourcesEditorRefresh").addEventListener("click", resourcesEditorAddLines);
byId("resourcesRegenerate").addEventListener("click", regenerateCurrentResources);
byId("resourcesLegend").addEventListener("click", toggleLegend);
byId("resourcesPercentage").addEventListener("click", togglePercentageMode);
byId("resourcesAssign").addEventListener("click", enterResourceAssignMode);
byId("resourcesAdd").addEventListener("click", resourceAdd);
byId("resourcesRestore").addEventListener("click", resourcesRestoreDefaults);
byId("resourcesExport").addEventListener("click", downloadResourcesData);
byId("resourcesUnpinAll").addEventListener("click", unpinAllResources);
body.addEventListener("click", function (ev) {
const el = ev.target,
cl = el.classList,
line = el.parentNode;
const resource = Resources.get(+line.dataset.id);
if (cl.contains("resourceIcon")) return changeIcon(resource, line, el);
if (cl.contains("resourceCategory")) return changeCategory(resource, line, el);
if (cl.contains("resourceModel")) return changeModel(resource, line, el);
if (cl.contains("resourceBonus")) return changeBonus(resource, line, el);
if (cl.contains("icon-pin")) return pinResource(resource, el);
if (cl.contains("icon-trash-empty")) return removeResource(resource, line);
});
body.addEventListener("change", function (ev) {
const el = ev.target,
cl = el.classList,
line = el.parentNode;
const resource = Resources.get(+line.dataset.id);
if (cl.contains("resourceName")) return changeName(resource, el.value, line);
if (cl.contains("resourceValue")) return changeValue(resource, el.value, line);
if (cl.contains("resourceChance")) return changeChance(resource, el.value, line);
});
function getBonusIcon(bonus) {
if (bonus === "fleet") return `<span data-tip="Fleet bonus" class="icon-anchor"></span>`;
if (bonus === "defence") return `<span data-tip="Defence bonus" class="icon-chess-rook"></span>`;
if (bonus === "prestige") return `<span data-tip="Prestige bonus" class="icon-star"></span>`;
if (bonus === "artillery") return `<span data-tip="Artillery bonus" class="icon-rocket"></span>`;
if (bonus === "infantry") return `<span data-tip="Infantry bonus" class="icon-chess-pawn"></span>`;
if (bonus === "population") return `<span data-tip="Population bonus" class="icon-male"></span>`;
if (bonus === "archers") return `<span data-tip="Archers bonus" class="icon-dot-circled"></span>`;
if (bonus === "cavalry") return `<span data-tip="Cavalry bonus" class="icon-chess-knight"></span>`;
}
// 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 += `<div class="states resources"
data-id=${r.i} data-name="${r.name}" data-color="${r.color}"
data-category="${r.category}" data-chance="${r.chance}" data-bonus="${bonusString}"
data-value="${r.value}" data-model="${r.model}" data-cells="${r.cells}">
<svg data-tip="Resource icon. Click to change" width="2em" height="2em" class="resourceIcon">
<circle cx="50%" cy="50%" r="42%" fill="${r.color}" stroke="${stroke}"/>
<use href="#${r.icon}" x="10%" y="10%" width="80%" height="80%"/>
</svg>
<input data-tip="Resource name. Click and category to change" class="resourceName" value="${r.name}" autocorrect="off" spellcheck="false">
<div data-tip="Resource category. Select to change" class="resourceCategory">${r.category}</div>
<input data-tip="Resource generation chance in eligible cell. Click and type to change" class="resourceChance" value="${r.chance}" type="number" min=0 max=100 step=.1 />
<div data-tip="Number of cells with resource" class="resourceCells">${r.cells}</div>
<div data-tip="Resource spread model. Click to change" class="resourceModel hide" ${addTitle(model, 8)}">${model}</div>
<input data-tip="Resource basic value. Click and type to change" class="resourceValue hide" value="${r.value}" type="number" min=0 max=100 step=1 />
<div data-tip="Resource bonus. Click to change" class="resourceBonus hide" title="${bonusString}">${bonusHTML || "<span style='opacity:0'>place</span>"}</div>
<span data-tip="Toogle resource exclusive visibility (pin)" class="icon-pin inactive hide"></span>
<span data-tip="Remove resource" class="icon-trash-empty hide"></span>
</div>`;
}
body.innerHTML = lines;
// update footer
byId("resourcesNumber").innerHTML = pack.resources.length;
// add listeners
body.querySelectorAll("div.states").forEach((el) => el.addEventListener("click", selectResourceOnLineClick));
if (body.dataset.type === "percentage") {
body.dataset.type = "absolute";
togglePercentageMode();
}
applySorting(resourcesHeader);
$("#resourcesEditor").dialog({width: fitContent()});
}
function changeCategory(resource, line, el) {
const categories = [...new Set(pack.resources.map((r) => r.category))].sort();
const categoryOptions = (category) => categories.map((c) => `<option ${c === category ? "selected" : ""} value="${c}">${c}</option>`).join("");
alertMessage.innerHTML = `
<div style="margin-bottom:.2em" data-tip="Select category from the list">
<div style="display: inline-block; width: 9em">Select category:</div>
<select style="width: 9em" id="resouceCategorySelect">${categoryOptions(line.dataset.category)}</select>
</div>
<div style="margin-bottom:.2em" data-tip="Type new category name">
<div style="display: inline-block; width: 9em">Custom category:</div>
<input style="width: 9em" id="resouceCategoryAdd" placeholder="Category name" />
</div>
`;
$("#alert").dialog({
resizable: false,
title: "Change category",
buttons: {
Cancel: function () {
$(this).dialog("close");
},
Apply: function () {
applyChanges();
$(this).dialog("close");
}
}
});
function applyChanges() {
const custom = byId("resouceCategoryAdd").value;
const select = byId("resouceCategorySelect").value;
const category = custom ? capitalize(custom) : select;
resource.category = line.dataset.category = el.innerHTML = category;
}
}
function changeModel(resource, line, el) {
const model = line.dataset.model;
const modelOptions = Object.keys(models)
.sort()
.map((m) => `<option ${m === model ? "selected" : ""} value="${m}">${m.replaceAll("_", " ")}</option>`)
.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 = `
<fieldset data-tip="Select one of the predefined spread models from the list" style="border: 1px solid #999; margin-bottom: 1em">
<legend>Predefined models</legend>
<div style="margin-bottom:.2em">
<div style="display: inline-block; width: 6em">Name:</div>
<select onchange="${onSelect}" style="width: 18em" id="resouceModelSelect">
<option value=""><i>Custom</i></option>
${modelOptions}
</select>
</div>
<div style="margin-bottom:.2em">
<div style="display: inline-block; width: 6em">Function:</div>
<div id="resouceModelFunction" style="display: inline-block; width: 18em; font-family: monospace; border: 1px solid #ccc; padding: 3px; font-size: .95em;vertical-align: middle">
${models[model] || " "}
</div>
</div>
</fieldset>
<fieldset data-tip="Advanced option. Define custom spread model, click on "Help" for details" style="border: 1px solid #999">
<legend>Custom model</legend>
<div style="margin-bottom:.2em">
<div style="display: inline-block; width: 6em">Name:</div>
<input style="width: 18em" id="resouceModelCustomName" value="${resource.custom ? resource.model : ''}" />
</div>
<div>
<div style="display: inline-block; width: 6em">Function:</div>
<input style="width: 18.75em; font-family: monospace; font-size: .95em" id="resouceModelCustomFunction" spellcheck="false" value="${resource.custom || ''}"/>
</div>
</fieldset>
<div id="resourceModelMessage" style="color: #b20000; margin: .4em 1em 0"></div>
`;
$("#alert").dialog({
resizable: false,
title: "Change spread model",
buttons: {
Help: () => openURL(wikiURL),
Cancel: function () {
$(this).dialog("close");
},
Apply: function () {
applyChanges(this);
}
}
});
function applyChanges(dialog) {
const customName = byId("resouceModelCustomName").value;
const customFn = byId("resouceModelCustomFunction").value;
const message = byId("resourceModelMessage");
if (customName && !customFn) return (message.innerHTML = 'Error. Custom model function is required');
if (!customName && customFn) return (message.innerHTML = 'Error. Custom model name is required');
message.innerHTML = '';
if (customName && customFn) {
try {
const allMethods = '{' + Object.keys(Resources.methods).join(', ') + '}';
const fn = new Function(allMethods, 'return ' + customFn);
fn({...Resources.methods});
} catch (err) {
message.innerHTML = 'Error. ' + err.message || err;
return;
}
resource.model = line.dataset.model = el.innerHTML = customName;
el.setAttribute('title', customName.length > 7 ? customName : '');
resource.custom = customFn;
$(dialog).dialog('close');
return;
}
const model = byId('resouceModelSelect').value;
if (!model) return (message.innerHTML = 'Error. Model is not set');
resource.model = line.dataset.model = el.innerHTML = model;
el.setAttribute('title', model.length > 7 ? model : '');
$(dialog).dialog('close');
}
}
function changeBonus(resource, line, el) {
const bonuses = [...new Set(pack.resources.map((r) => Object.keys(r.bonus)).flat())].sort();
const inputs = bonuses.map(
(bonus) => `<div style="margin-bottom:.2em">
${getBonusIcon(bonus)}
<div style="display: inline-block; width: 8em">${capitalize(bonus)}</div>
<input id="resourceBonus_${bonus}" style="width: 4.1em" type="number" step="1" min="0" max="9" value="${resource.bonus[bonus] || 0}" />
</div>`
);
alertMessage.innerHTML = inputs.join('');
$('#alert').dialog({
resizable: false,
title: 'Change bonus',
buttons: {
Cancel: function () {
$(this).dialog('close');
},
Apply: function () {
applyChanges();
$(this).dialog('close');
}
}
});
function applyChanges() {
const bonusObj = {};
bonuses.forEach((bonus) => {
const el = byId('resourceBonus_' + bonus);
const value = parseInt(el.value);
if (isNaN(value) || !value) return;
bonusObj[bonus] = value;
});
const bonusArray = Object.entries(bonusObj).map(e => Array(e[1]).fill(e[0])).flat(); //prettier-ignore
const bonusHTML = bonusArray.map((bonus) => getBonusIcon(bonus)).join('');
const bonusString = Object.entries(bonusObj).map((e) => e.join(': ')).join('; '); //prettier-ignore
resource.bonus = bonusObj;
el.innerHTML = bonusHTML || "<span style='opacity:0'>place</span>";
line.dataset.bonus = bonusString;
el.setAttribute('title', bonusString);
}
}
function changeName(resource, name, line) {
resource.name = line.dataset.name = name;
}
function changeValue(resource, value, line) {
resource.value = line.dataset.value = +value;
}
function changeChance(resource, chance, line) {
resource.chance = line.dataset.chance = +chance;
}
function changeIcon(resource, line, el) {
const standardIcons = Array.from(byId('resource-icons').querySelectorAll('symbol')).map((el) => el.id);
const standardIconsOptions = standardIcons.map((icon) => `<option value=${icon}>${icon}</option>`);
const customIconsEl = byId('defs-icons');
const customIcons = customIconsEl ? Array.from(byId('defs-icons').querySelectorAll('svg')).map((el) => el.id) : [];
const customIconsOptions = customIcons.map((icon) => `<option value=${icon}>${icon}</option>`);
const select = byId('resourceSelectIcon');
select.innerHTML = standardIconsOptions + customIconsOptions;
select.value = resource.icon;
const preview = byId('resourceIconPreview');
preview.setAttribute('href', '#' + resource.icon);
const viewBoxSection = byId('resourceIconEditorViewboxFields');
viewBoxSection.style.display = 'none';
$('#resourceIconEditor').dialog({
resizable: false,
title: 'Change Icon',
buttons: {
Cancel: function () {
$(this).dialog('close');
},
'Change color': () => changeColor(resource, line, el),
Apply: function () {
$(this).dialog('close');
resource.icon = select.value;
line.querySelector('svg.resourceIcon > use').setAttribute('href', '#' + select.value);
drawResources();
}
},
position: {my: 'center bottom', at: 'center', of: 'svg'}
});
const uploadTo = byId('defs-icons');
const onUpload = (type, id) => {
preview.setAttribute('href', '#' + id);
select.innerHTML += `<option value=${id}>${id}</option>`;
select.value = id;
if (type === 'image') return;
// let user set viewBox for svg image
const el = byId(id);
viewBoxSection.style.display = 'block';
const viewBoxAttr = el.getAttribute('viewBox');
const initialViewBox = viewBoxAttr ? viewBoxAttr.split(' ') : [0, 0, 200, 200];
const inputs = viewBoxSection.querySelectorAll('input');
const changeInput = () => {
const viewBox = Array.from(inputs)
.map((input) => input.value)
.join(' ');
el.setAttribute('viewBox', viewBox);
};
inputs.forEach((input, i) => {
input.value = initialViewBox[i];
input.onchange = changeInput;
});
};
// add listeners
select.onchange = () => preview.setAttribute('href', '#' + select.value);
byId('resourceUploadIconRaster').onclick = () => imageToLoad.click();
byId('resourceUploadIconVector').onclick = () => svgToLoad.click();
byId('imageToLoad').onchange = () => uploadImage('image', uploadTo, onUpload);
byId('svgToLoad').onchange = () => uploadImage('svg', uploadTo, onUpload);
}
function uploadImage(type, uploadTo, callback) {
const input = type === 'image' ? byId('imageToLoad') : byId('svgToLoad');
const file = input.files[0];
input.value = '';
if (file.size > 200000) return tip(`File is too big, please optimize file size up to 200kB and re-upload. Recommended size is 48x48 px and up to 10kB`, true, 'error', 5000);
const reader = new FileReader();
reader.onload = function (readerEvent) {
const result = readerEvent.target.result;
const id = 'resource-custom-' + Math.random().toString(36).slice(-6);
if (type === 'image') {
const svg = `<svg id="${id}" xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><image x="0" y="0" width="200" height="200" href="${result}"/></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? <br>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? <br>This action cannot be reverted';
const onConfirm = () => {
delete pack.resources;
regenerateResources();
};
confirmationDialog({title: 'Restore default resources', message, confirm: 'Restore', onConfirm});
}
function toggleLegend() {
if (legend.selectAll('*').size()) {
clearLegend();
return;
}
const data = pack.resources
.filter((r) => r.i && r.cells)
.sort((a, b) => b.cells - a.cells)
.map((r) => [r.i, r.color, r.name]);
drawLegend('Resources', data);
}
function togglePercentageMode() {
if (body.dataset.type === "absolute") {
body.dataset.type = "percentage";
const totalCells = pack.cells.resource.filter((r) => r !== 0).length;
body.querySelectorAll(':scope > div').forEach(function (el) {
el.querySelector('.cells').innerHTML = rn((+el.dataset.cells / totalCells) * 100) + '%';
});
} else {
body.dataset.type = 'absolute';
resourcesEditorAddLines();
}
}
function enterResourceAssignMode() {
if (this.classList.contains('pressed')) return exitResourceAssignMode();
customization = 14;
this.classList.add('pressed');
if (!layerIsOn('toggleResources')) toggleResources();
if (!layerIsOn('toggleCells')) {
const toggler = byId('toggleCells');
toggler.dataset.forced = true;
toggleCells();
}
document
.getElementById('resourcesEditor')
.querySelectorAll('.hide')
.forEach((el) => el.classList.add('hidden'));
byId('resourcesFooter').style.display = 'none';
body.querySelectorAll('.resourceName, .resourceCategory, .resourceChance, .resourceCells, svg').forEach((e) => (e.style.pointerEvents = 'none'));
$('#resourcesEditor').dialog({position: {my: 'right top', at: 'right-10 top+10', of: 'svg', collision: 'fit'}});
tip('Select resource line in editor, click on cells to remove or add a resource', true);
viewbox.on('click', changeResourceOnCellClick);
body.querySelector('div').classList.add('selected');
const someArePinned = pack.resources.some((resource) => resource.pinned);
if (someArePinned) unpinAllResources();
}
function selectResourceOnLineClick() {
if (customization !== 14) return;
//if (this.parentNode.id !== "statesBodySection") return;
body.querySelector('div.selected').classList.remove('selected');
this.classList.add('selected');
}
function changeResourceOnCellClick() {
const point = d3.mouse(this);
const i = findCell(point[0], point[1]);
const selected = body.querySelector('div.selected');
if (!selected) return;
if (pack.cells.resource[i]) {
const resourceToRemove = Resources.get(pack.cells.resource[i]);
if (resourceToRemove) resourceToRemove.cells -= 1;
body.querySelector("div.states[data-id='" + resourceToRemove.i + "'] > .resourceCells").innerHTML = resourceToRemove.cells;
pack.cells.resource[i] = 0;
} else {
const resourceId = +selected.dataset.id;
const resource = Resources.get(resourceId);
resource.cells += 1;
body.querySelector("div.states[data-id='" + resourceId + "'] > .resourceCells").innerHTML = resource.cells;
pack.cells.resource[i] = resourceId;
}
goods.selectAll('*').remove();
drawResources();
}
function exitResourceAssignMode(close) {
customization = 0;
byId('resourcesAssign').classList.remove('pressed');
if (layerIsOn('toggleCells')) {
const toggler = byId('toggleCells');
if (toggler.dataset.forced) toggleCells();
delete toggler.dataset.forced;
}
document
.getElementById('resourcesEditor')
.querySelectorAll('.hide')
.forEach((el) => el.classList.remove('hidden'));
byId('resourcesFooter').style.display = 'block';
body.querySelectorAll('.resourceName, .resourceCategory, .resourceChance, .resourceCells, svg').forEach((e) => delete e.style.pointerEvents);
!close && $('#resourcesEditor').dialog({position: {my: 'right top', at: 'right-10 top+10', of: 'svg', collision: 'fit'}});
restoreDefaultEvents();
clearMainTip();
const selected = body.querySelector('div.selected');
if (selected) selected.classList.remove('selected');
}
function resourceAdd() {
if (pack.resources.length >= 256) return tip('Maximum number of resources is reached', false, 'error');
let i = last(pack.resources).i;
while (Resources.get(i)) {
i++;
}
const resource = {i, name: 'Resource' + i, category: 'Unknown', icon: 'resource-unknown', color: '#ff5959', value: 1, chance: 10, model: 'habitability', bonus: {population: 1}, cells: 0};
pack.resources.push(resource);
tip('Resource is added', false, 'success', 3000);
resourcesEditorAddLines();
}
function downloadResourcesData() {
let data = 'Id,Resource,Color,Category,Value,Bonus,Chance,Model,Cells\n'; // headers
body.querySelectorAll(':scope > div').forEach(function (el) {
data += el.dataset.id + ',';
data += el.dataset.name + ',';
data += el.dataset.color + ',';
data += el.dataset.category + ',';
data += el.dataset.value + ',';
data += el.dataset.bonus + ',';
data += el.dataset.chance + ',';
data += el.dataset.model + ',';
data += el.dataset.cells + '\n';
});
const name = getFileName('Resources') + '.csv';
downloadFile(data, name);
}
function pinResource(resource, el) {
const pin = el.classList.contains('inactive');
el.classList.toggle('inactive');
if (pin) resource.pinned = pin;
else delete resource.pinned;
goods.selectAll('*').remove();
drawResources();
// manage top unpin all button state
const someArePinned = pack.resources.some((resource) => resource.pinned);
const unpinAll = byId('resourcesUnpinAll');
someArePinned ? unpinAll.classList.remove('hidden') : unpinAll.classList.add('hidden');
}
function unpinAllResources() {
pack.resources.forEach((resource) => delete resource.pinned);
goods.selectAll('*').remove();
drawResources();
byId('resourcesUnpinAll').classList.add('hidden');
body.querySelectorAll(':scope > div > span.icon-pin').forEach((el) => el.classList.add('inactive'));
}
function removeResource(res, line) {
if (customization) return;
const message = 'Are you sure you want to remove the resource? <br>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 = "";
}
}

View file

@ -351,6 +351,13 @@ function selectStyleElement() {
emblemsBurgSizeInput.value = emblems.select("#burgEmblems").attr("data-size") || 1;
}
if (styleElement === "goods") {
styleStrokeWidth.style.display = "block";
styleStrokeWidthInput.value = el.attr("stroke-width") || "";
styleResources.style.display = "block";
styleResourcesCircle.checked = +el.attr("data-circle");
}
// update group options
styleGroupSelect.options.length = 0; // remove all options
if (["routes", "labels", "coastline", "lakes", "anchors", "burgIcons", "borders", "terrs"].includes(styleElement)) {
@ -976,6 +983,12 @@ emblemsBurgSizeInput.on("change", e => {
drawEmblems();
});
styleResourcesCircle.on("change", e => {
goods.attr("data-circle", +this.checked);
goods.selectAll("*").remove();
drawResources();
})
// request a URL to image to be used as a texture
function textureProvideURL() {
alertMessage.innerHTML = /* html */ `Provide a texture image URL:

View file

@ -14,6 +14,7 @@ toolsContent.addEventListener("click", function (event) {
else if (button === "editProvincesButton") editProvinces();
else if (button === "editDiplomacyButton") editDiplomacy();
else if (button === "editCulturesButton") editCultures();
else if (button === "editResourcesButton") editResources();
else if (button === "editReligions") editReligions();
else if (button === "editEmblemButton") openEmblemEditor();
else if (button === "editNamesBaseButton") editNamesbase();
@ -90,6 +91,7 @@ function processFeatureRegeneration(event, button) {
else if (button === "regenerateProvinces") regenerateProvinces();
else if (button === "regenerateBurgs") regenerateBurgs();
else if (button === "regenerateEmblems") regenerateEmblems();
else if (button === "regenerateResources") regenerateResources();
else if (button === "regenerateReligions") regenerateReligions();
else if (button === "regenerateCultures") regenerateCultures();
else if (button === "regenerateMilitary") regenerateMilitary();
@ -126,6 +128,13 @@ function regenerateRoutes() {
if (layerIsOn("toggleRoutes")) drawRoutes();
}
function regenerateResources() {
Resources.generate();
goods.selectAll("*").remove();
if (layerIsOn("toggleResources")) drawResources();
refreshAllEditors();
}
function regenerateRivers() {
Rivers.generate();
Rivers.specify();