diff --git a/index.html b/index.html index 078d5411..b0eaf33f 100644 --- a/index.html +++ b/index.html @@ -4964,7 +4964,18 @@ >Model: - + Temperature: + + + Key: @@ -8033,7 +8044,6 @@ - @@ -8048,7 +8058,7 @@ - + @@ -8064,14 +8074,14 @@ - - - + + + - + - + @@ -8110,10 +8120,10 @@ - + - - + + @@ -8131,8 +8141,8 @@ - - + + diff --git a/libs/priority-queue.min.js b/libs/priority-queue.min.js deleted file mode 100644 index acf2506b..00000000 --- a/libs/priority-queue.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).PriorityQueue=t()}}(function(){return function t(e,i,r){function o(n,s){if(!i[n]){if(!e[n]){var h="function"==typeof require&&require;if(!s&&h)return h(n,!0);if(a)return a(n,!0);var u=new Error("Cannot find module '"+n+"'");throw u.code="MODULE_NOT_FOUND",u}var p=i[n]={exports:{}};e[n][0].call(p.exports,function(t){var i=e[n][1][t];return o(i||t)},p,p.exports,t,e,i,r)}return i[n].exports}for(var a="function"==typeof require&&require,n=0;n>>1],e)>=0?o=a+1:r=a;return o},e.exports=function(){function t(t){var e;this.options=t,this.comparator=this.options.comparator,this.data=(null!=(e=this.options.initialValues)?e.slice(0):void 0)||[],this.data.sort(this.comparator).reverse()}return t.prototype.queue=function(t){var e;e=r(this.data,t,this.comparator),this.data.splice(e,0,t)},t.prototype.dequeue=function(){return this.data.pop()},t.prototype.peek=function(){return this.data[this.data.length-1]},t.prototype.clear=function(){this.data.length=0},t}()},{}],4:[function(t,e,i){e.exports=function(){function t(t){var e,i,r,o,a,n,s,h;for(this.comparator=(null!=t?t.comparator:void 0)||function(t,e){return t-e},this.pageSize=(null!=t?t.pageSize:void 0)||512,this.length=0,s=0;1<a;0<=a?++i:--i)e.push(null);if(this._memory=[],this._mask=this.pageSize-1,t.initialValues)for(r=0,o=(n=t.initialValues).length;r0&&(this._write(1,e),this._bubbleDown(1,e)),t},t.prototype.peek=function(){return this._read(1)},t.prototype.clear=function(){this.length=0,this._memory.length=0},t.prototype._write=function(t,e){var i;for(i=t>>this._shift;i>=this._memory.length;)this._memory.push(this._emptyMemoryPageTemplate.slice(0));return this._memory[i][t&this._mask]=e},t.prototype._read=function(t){return this._memory[t>>this._shift][t&this._mask]},t.prototype._bubbleUp=function(t,e){var i,r,o,a;for(i=this.comparator;t>1&&(r=t&this._mask,t3?o=t&~this._mask|r>>1:r<2?(o=t-this.pageSize>>this._shift,o+=o&~(this._mask>>1),o|=this.pageSize>>1):o=t-2,!(i(a=this._read(o),e)<0));)this._write(o,e),this._write(t,a),t=o},t.prototype._bubbleDown=function(t,e){var i,r,o,a,n;for(n=this.comparator;tthis._mask&&!(t&this._mask-1)?i=r=t+2:t&this.pageSize>>1?(i=(t&~this._mask)>>1,r=(i=(i|=t&this._mask>>1)+1<0)for(t=e=1,i=this.data.length;1<=i?ei;t=1<=i?++e:--e)this._bubbleUp(t)},t.prototype.queue=function(t){this.data.push(t),this._bubbleUp(this.data.length-1)},t.prototype.dequeue=function(){var t,e;return e=this.data[0],t=this.data.pop(),this.data.length>0&&(this.data[0]=t,this._bubbleDown(0)),e},t.prototype.peek=function(){return this.data[0]},t.prototype.clear=function(){this.length=0,this.data.length=0},t.prototype._bubbleUp=function(t){for(var e,i;t>0&&(e=t-1>>>1,this.comparator(this.data[t],this.data[e])<0);)i=this.data[e],this.data[e]=this.data[t],this.data[t]=i,t=e},t.prototype._bubbleDown=function(t){var e,i,r,o,a;for(e=this.data.length-1;o=(i=1+(t<<1))+1,r=t,i<=e&&this.comparator(this.data[i],this.data[r])<0&&(r=i),o<=e&&this.comparator(this.data[o],this.data[r])<0&&(r=o),r!==t;)a=this.data[r],this.data[r]=this.data[t],this.data[t]=a,t=r},t}()},{}]},{},[1])(1)}); \ No newline at end of file diff --git a/main.js b/main.js index ed06c9e5..a38d96c1 100644 --- a/main.js +++ b/main.js @@ -4,7 +4,7 @@ // set debug options const PRODUCTION = location.hostname && location.hostname !== "localhost" && location.hostname !== "127.0.0.1"; -const DEBUG = localStorage.getItem("debug"); +const DEBUG = JSON.safeParse(localStorage.getItem("debug")) || {}; const INFO = true; const TIME = true; const WARN = true; @@ -915,7 +915,7 @@ function calculateTemperatures() { const [, y] = grid.points[rowCellId]; const rowLatitude = mapCoordinates.latN - (y / graphHeight) * mapCoordinates.latT; // [90; -90] const tempSeaLevel = calculateSeaLevelTemp(rowLatitude); - DEBUG && console.info(`${rn(rowLatitude)}° sea temperature: ${rn(tempSeaLevel)}°C`); + DEBUG.temperature && console.info(`${rn(rowLatitude)}° sea temperature: ${rn(tempSeaLevel)}°C`); for (let cellId = rowCellId; cellId < rowCellId + grid.cellsX; cellId++) { const tempAltitudeDrop = getAltitudeTemperatureDrop(cells.h[cellId]); diff --git a/modules/burgs-and-states.js b/modules/burgs-and-states.js index 98b0db67..8e3879c1 100644 --- a/modules/burgs-and-states.js +++ b/modules/burgs-and-states.js @@ -286,7 +286,8 @@ window.BurgsAndStates = (() => { const {cells, states, cultures, burgs} = pack; cells.state = cells.state || new Uint16Array(cells.i.length); - const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); + + const queue = new FlatQueue(); const cost = []; const globalGrowthRate = byId("growthRate").valueAsNumber || 1; @@ -307,12 +308,13 @@ window.BurgsAndStates = (() => { cells.state[capitalCell] = state.i; const cultureCenter = cultures[state.culture].center; const b = cells.biome[cultureCenter]; // state native biome - queue.queue({e: state.center, p: 0, s: state.i, b}); + queue.push({e: state.center, p: 0, s: state.i, b}, 0); cost[state.center] = 1; } while (queue.length) { - const next = queue.dequeue(); + const next = queue.pop(); + const {e, p, s, b} = next; const {type, culture} = states[s]; @@ -335,7 +337,7 @@ window.BurgsAndStates = (() => { if (!cost[e] || totalCost < cost[e]) { if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell cost[e] = totalCost; - queue.queue({e, p: totalCost, s, b}); + queue.push({e, p: totalCost, s, b}, totalCost); } }); } diff --git a/modules/cultures-generator.js b/modules/cultures-generator.js index f2203146..34dc5edd 100644 --- a/modules/cultures-generator.js +++ b/modules/cultures-generator.js @@ -518,7 +518,7 @@ window.Cultures = (function () { TIME && console.time("expandCultures"); const {cells, cultures} = pack; - const queue = new PriorityQueue({comparator: (a, b) => a.priority - b.priority}); + const queue = new FlatQueue(); const cost = []; const neutralRate = byId("neutralRate")?.valueAsNumber || 1; @@ -538,11 +538,11 @@ window.Cultures = (function () { for (const culture of cultures) { if (!culture.i || culture.removed || culture.lock) continue; - queue.queue({cellId: culture.center, cultureId: culture.i, priority: 0}); + queue.push({cellId: culture.center, cultureId: culture.i, priority: 0}, 0); } while (queue.length) { - const {cellId, priority, cultureId} = queue.dequeue(); + const {cellId, priority, cultureId} = queue.pop(); const {type, expansionism} = cultures[cultureId]; cells.c[cellId].forEach(neibCellId => { @@ -566,7 +566,7 @@ window.Cultures = (function () { if (!cost[neibCellId] || totalCost < cost[neibCellId]) { if (cells.pop[neibCellId] > 0) cells.culture[neibCellId] = cultureId; // assign culture to populated cell cost[neibCellId] = totalCost; - queue.queue({cellId: neibCellId, cultureId, priority: totalCost}); + queue.push({cellId: neibCellId, cultureId, priority: totalCost}, totalCost); } }); } diff --git a/modules/io/cloud.js b/modules/io/cloud.js index b25b6a90..17ca92db 100644 --- a/modules/io/cloud.js +++ b/modules/io/cloud.js @@ -60,7 +60,7 @@ window.Cloud = (function () { async save(fileName, contents) { const resp = await this.call("filesUpload", {path: "/" + fileName, contents}); - DEBUG && console.info("Dropbox response:", resp); + DEBUG.cloud && console.info("Dropbox response:", resp); return true; }, @@ -104,7 +104,7 @@ window.Cloud = (function () { // Callback function for auth window async setDropBoxToken(token) { - DEBUG && console.info("Access token:", token); + DEBUG.cloud && console.info("Access token:", token); setToken(this.name, token); await this.connect(token); this.authWindow.close(); @@ -131,7 +131,7 @@ window.Cloud = (function () { allow_download: true }; const resp = await this.call("sharingCreateSharedLinkWithSettings", {path, settings}); - DEBUG && console.info("Dropbox link object:", resp.result); + DEBUG.cloud && console.info("Dropbox link object:", resp.result); return resp.result.url; } }; diff --git a/modules/io/load.js b/modules/io/load.js index cd00b28c..05819ce7 100644 --- a/modules/io/load.js +++ b/modules/io/load.js @@ -13,7 +13,7 @@ async function quickLoad() { async function loadFromDropbox() { const mapPath = byId("loadFromDropboxSelect")?.value; - DEBUG && console.info("Loading map from Dropbox:", mapPath); + console.info("Loading map from Dropbox:", mapPath); const blob = await Cloud.providers.dropbox.load(mapPath); uploadMap(blob); } diff --git a/modules/provinces-generator.js b/modules/provinces-generator.js index 8563f682..c0f8e8f7 100644 --- a/modules/provinces-generator.js +++ b/modules/provinces-generator.js @@ -77,18 +77,18 @@ window.Provinces = (function () { }); // expand generated provinces - const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); + const queue = new FlatQueue(); const cost = []; provinces.forEach(p => { if (!p.i || p.removed || isProvinceLocked(p)) return; provinceIds[p.center] = p.i; - queue.queue({e: p.center, p: 0, province: p.i, state: p.state}); + queue.push({e: p.center, province: p.i, state: p.state, p: 0}, 0); cost[p.center] = 1; }); while (queue.length) { - const {e, p, province, state} = queue.dequeue(); + const {e, p, province, state} = queue.pop(); cells.c[e].forEach(e => { if (isProvinceCellLocked(e)) return; // do not overwrite cell of locked provinces @@ -103,7 +103,7 @@ window.Provinces = (function () { if (!cost[e] || totalCost < cost[e]) { if (land) provinceIds[e] = province; // assign province to a cell cost[e] = totalCost; - queue.queue({e, p: totalCost, province, state}); + queue.push({e, province, state, p: totalCost}, totalCost); } }); } @@ -158,9 +158,9 @@ window.Provinces = (function () { // expand province const cost = []; cost[center] = 1; - queue.queue({e: center, p: 0}); + queue.push({e: center, p: 0}, 0); while (queue.length) { - const {e, p} = queue.dequeue(); + const {e, p} = queue.pop(); cells.c[e].forEach(nextCellId => { if (provinceIds[nextCellId]) return; @@ -173,7 +173,7 @@ window.Provinces = (function () { if (!cost[nextCellId] || totalCost < cost[nextCellId]) { if (land && cells.state[nextCellId] === s.i) provinceIds[nextCellId] = provinceId; // assign province to a cell cost[nextCellId] = totalCost; - queue.queue({e: nextCellId, p: totalCost}); + queue.push({e: nextCellId, p: totalCost}, totalCost); } }); } @@ -216,15 +216,15 @@ window.Provinces = (function () { // check if there is a land way within the same state between two cells function isPassable(from, to) { if (cells.f[from] !== cells.f[to]) return false; // on different islands - const queue = [from], + const passableQueue = [from], used = new Uint8Array(cells.i.length), state = cells.state[from]; - while (queue.length) { - const current = queue.pop(); + while (passableQueue.length) { + const current = passableQueue.pop(); if (current === to) return true; // way is found cells.c[current].forEach(c => { if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) return; - queue.push(c); + passableQueue.push(c); used[c] = 1; }); } diff --git a/modules/religions-generator.js b/modules/religions-generator.js index 5e5e08f6..2bb972db 100644 --- a/modules/religions-generator.js +++ b/modules/religions-generator.js @@ -695,7 +695,7 @@ window.Religions = (function () { const {cells, routes} = pack; const religionIds = spreadFolkReligions(religions); - const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); + const queue = new FlatQueue(); const cost = []; // limit cost for organized religions growth @@ -705,14 +705,14 @@ window.Religions = (function () { .filter(r => r.i && !r.lock && r.type !== "Folk" && !r.removed) .forEach(r => { religionIds[r.center] = r.i; - queue.queue({e: r.center, p: 0, r: r.i, s: cells.state[r.center]}); + queue.push({e: r.center, p: 0, r: r.i, s: cells.state[r.center]}, 0); cost[r.center] = 1; }); const religionsMap = new Map(religions.map(r => [r.i, r])); while (queue.length) { - const {e: cellId, p, r, s: state} = queue.dequeue(); + const {e: cellId, p, r, s: state} = queue.pop(); const {culture, expansion, expansionism} = religionsMap.get(r); cells.c[cellId].forEach(nextCell => { @@ -732,7 +732,7 @@ window.Religions = (function () { if (cells.culture[nextCell]) religionIds[nextCell] = r; // assign religion to cell cost[nextCell] = totalCost; - queue.queue({e: nextCell, p: totalCost, r, s: state}); + queue.push({e: nextCell, p: totalCost, r, s: state}, totalCost); } }); } diff --git a/modules/renderers/draw-state-labels.js b/modules/renderers/draw-state-labels.js index ab49437d..d30d185c 100644 --- a/modules/renderers/draw-state-labels.js +++ b/modules/renderers/draw-state-labels.js @@ -47,8 +47,10 @@ function drawStateLabels(list) { const pathPoints = [[ray1.x, ray1.y], state.pole, [ray2.x, ray2.y]]; if (ray1.x > ray2.x) pathPoints.reverse(); - DEBUG && drawPoint(state.pole, {color: "black", radius: 1}); - DEBUG && drawPath(pathPoints, {color: "black", width: 0.2}); + if (DEBUG.stateLabels) { + drawPoint(state.pole, {color: "black", radius: 1}); + drawPath(pathPoints, {color: "black", width: 0.2}); + } labelPaths.push([state.i, pathPoints]); } @@ -163,9 +165,11 @@ function drawStateLabels(list) { const offset1 = [x + -dy * offset, y + dx * offset]; const offset2 = [x + dy * offset, y + -dx * offset]; - DEBUG && drawPoint([x, y], {color: isInsideState(x, y) ? "blue" : "red", radius: 0.8}); - DEBUG && drawPoint(offset1, {color: isInsideState(...offset1) ? "blue" : "red", radius: 0.4}); - DEBUG && drawPoint(offset2, {color: isInsideState(...offset2) ? "blue" : "red", radius: 0.4}); + if (DEBUG.stateLabels) { + drawPoint([x, y], {color: isInsideState(x, y) ? "blue" : "red", radius: 0.8}); + drawPoint(offset1, {color: isInsideState(...offset1) ? "blue" : "red", radius: 0.4}); + drawPoint(offset2, {color: isInsideState(...offset2) ? "blue" : "red", radius: 0.4}); + } const inState = isInsideState(x, y) && isInsideState(...offset1) && isInsideState(...offset2); if (!inState) break; diff --git a/modules/submap.js b/modules/submap.js new file mode 100644 index 00000000..3c783fb0 --- /dev/null +++ b/modules/submap.js @@ -0,0 +1,407 @@ +"use strict"; + +window.Submap = (function () { + const isWater = (pack, id) => pack.cells.h[id] < 20; + const inMap = (x, y) => x > 0 && x < graphWidth && y > 0 && y < graphHeight; + + /* + generate new map based on an existing one (resampling parentMap) + parentMap: {seed, grid, pack} from original map + options = { + projection: f(Number,Number)->[Number, Number] + function to calculate new coordinates + inverse: g(Number,Number)->[Number, Number] + inverse of f + depressRivers: Bool carve out riverbeds? + smoothHeightMap: Bool run smooth filter on heights + addLakesInDepressions: call FMG original funtion on heightmap + + lockMarkers: Bool Auto lock all copied markers + lockBurgs: Bool Auto lock all copied burgs + } + */ + function resample(parentMap, options) { + const projection = options.projection; + const inverse = options.inverse; + const stage = s => INFO && console.info("SUBMAP:", s); + const timeStart = performance.now(); + invokeActiveZooming(); + + // copy seed + seed = parentMap.seed; + Math.random = aleaPRNG(seed); + INFO && console.group("SubMap with seed: " + seed); + + applyGraphSize(); + grid = generateGrid(); + + drawScaleBar(scaleBar, scale); + fitScaleBar(scaleBar, svgWidth, svgHeight); + + const resampler = (points, qtree, f) => { + for (const [i, [x, y]] of points.entries()) { + const [tx, ty] = inverse(x, y); + const oldid = qtree.find(tx, ty, Infinity)[2]; + f(i, oldid); + } + }; + + stage("Resampling heightmap, temperature and precipitation"); + // resample heightmap from old WorldState + const n = grid.points.length; + grid.cells.h = new Uint8Array(n); // heightmap + grid.cells.temp = new Int8Array(n); // temperature + grid.cells.prec = new Uint8Array(n); // precipitation + const reverseGridMap = new Uint32Array(n); // cellmap from new -> oldcell + + const oldGrid = parentMap.grid; + // build cache old -> [newcelllist] + const forwardGridMap = parentMap.grid.points.map(_ => []); + resampler(grid.points, parentMap.pack.cells.q, (id, oldid) => { + const cid = parentMap.pack.cells.g[oldid]; + grid.cells.h[id] = oldGrid.cells.h[cid]; + grid.cells.temp[id] = oldGrid.cells.temp[cid]; + grid.cells.prec[id] = oldGrid.cells.prec[cid]; + if (options.depressRivers) forwardGridMap[cid].push(id); + reverseGridMap[id] = cid; + }); + // TODO: add smooth/noise function for h, temp, prec n times + + // smooth heightmap + // smoothing should never change cell type (land->water or water->land) + + if (options.smoothHeightMap) { + const gcells = grid.cells; + gcells.h.forEach((h, i) => { + const hs = gcells.c[i].map(c => gcells.h[c]); + hs.push(h); + gcells.h[i] = h >= 20 ? Math.max(d3.mean(hs), 20) : Math.min(d3.mean(hs), 19); + }); + } + + if (options.depressRivers) { + stage("Generating riverbeds"); + const rbeds = new Uint16Array(grid.cells.i.length); + + // and erode riverbeds + parentMap.pack.rivers.forEach(r => + r.cells.forEach(oldpc => { + if (oldpc < 0) return; // ignore out-of-map marker (-1) + const oldc = parentMap.pack.cells.g[oldpc]; + const targetCells = forwardGridMap[oldc]; + if (!targetCells) throw "TargetCell shouldn't be empty"; + targetCells.forEach(c => { + if (grid.cells.h[c] < 20) return; + rbeds[c] = 1; + }); + }) + ); + // raise every land cell a bit except riverbeds + grid.cells.h.forEach((h, i) => { + if (rbeds[i] || h < 20) return; + grid.cells.h[i] = Math.min(h + 2, 100); + }); + } + + stage("Detect features, ocean and generating lakes"); + Features.markupGrid(); + + addLakesInDeepDepressions(); + openNearSeaLakes(); + + OceanLayers(); + + calculateMapCoordinates(); + calculateTemperatures(); + generatePrecipitation(); + stage("Cell cleanup"); + reGraph(); + + // remove misclassified cells + stage("Define coastline"); + Features.markupPack(); + createDefaultRuler(); + + // Packed Graph + const oldCells = parentMap.pack.cells; + const forwardMap = parentMap.pack.cells.p.map(_ => []); // old -> [newcelllist] + + const pn = pack.cells.i.length; + const cells = pack.cells; + cells.culture = new Uint16Array(pn); + cells.state = new Uint16Array(pn); + cells.burg = new Uint16Array(pn); + cells.religion = new Uint16Array(pn); + cells.province = new Uint16Array(pn); + + stage("Resampling culture, state and religion map"); + for (const [id, gridCellId] of cells.g.entries()) { + const oldGridId = reverseGridMap[gridCellId]; + if (oldGridId === undefined) { + console.error("Can not find old cell id", reverseGridMap, "in", gridCellId); + continue; + } + // find old parent's children + const oldChildren = oldCells.i.filter(oid => oldCells.g[oid] == oldGridId); + let oldid; // matching cell on the original map + + if (!oldChildren.length) { + // it *must* be a (deleted) deep ocean cell + if (!oldGrid.cells.h[oldGridId] < 20) { + console.error(`Warning, ${gridCellId} should be water cell, not ${oldGrid.cells.h[oldGridId]}`); + continue; + } + // find replacement: closest water cell + const [ox, oy] = cells.p[id]; + const [tx, ty] = inverse(x, y); + oldid = oldCells.q.find(tx, ty, Infinity)[2]; + if (!oldid) { + console.warn("Warning, no id found in quad", id, "parent", gridCellId); + continue; + } + } else { + // find closest children (packcell) on the parent map + const distance = x => (x[0] - cells.p[id][0]) ** 2 + (x[1] - cells.p[id][1]) ** 2; + let d = Infinity; + oldChildren.forEach(oid => { + // this should be always true, unless some algo modded the height! + if (isWater(parentMap.pack, oid) !== isWater(pack, id)) { + console.warn(`cell sank because of addLakesInDepressions: ${oid}`); + } + const [oldpx, oldpy] = oldCells.p[oid]; + const nd = distance(projection(oldpx, oldpy)); + if (isNaN(nd)) { + console.error("Distance is not a number!", "Old point:", oldpx, oldpy); + } + if (nd < d) [d, oldid] = [nd, oid]; + }); + if (oldid === undefined) { + console.warn("Warning, no match for", id, "(parent:", gridCellId, ")"); + continue; + } + } + + if (isWater(pack, id) !== isWater(parentMap.pack, oldid)) { + WARN && console.warn("Type discrepancy detected:", id, oldid, `${pack.cells.t[id]} != ${oldCells.t[oldid]}`); + } + + cells.culture[id] = oldCells.culture[oldid]; + cells.state[id] = oldCells.state[oldid]; + cells.religion[id] = oldCells.religion[oldid]; + cells.province[id] = oldCells.province[oldid]; + // reverseMap.set(id, oldid) + forwardMap[oldid].push(id); + } + + stage("Regenerating river network"); + Rivers.generate(); + + // biome calculation based on (resampled) grid.cells.temp and prec + // it's safe to recalculate. + stage("Regenerating Biome"); + Biomes.define(); + // recalculate suitability and population + // TODO: normalize according to the base-map + rankCells(); + + stage("Porting Cultures"); + pack.cultures = parentMap.pack.cultures; + // fix culture centers + const validCultures = new Set(pack.cells.culture); + pack.cultures.forEach((c, i) => { + if (!i) return; // ignore wildlands + if (!validCultures.has(i)) { + c.removed = true; + c.center = null; + return; + } + const newCenters = forwardMap[c.center]; + c.center = newCenters.length ? newCenters[0] : pack.cells.culture.findIndex(x => x === i); + }); + + stage("Porting and locking burgs"); + copyBurgs(parentMap, projection, options); + + // transfer states, mark states without land as removed. + stage("Porting states"); + const validStates = new Set(pack.cells.state); + pack.states = parentMap.pack.states; + // keep valid states and neighbors only + pack.states.forEach((s, i) => { + if (!s.i || s.removed) return; // ignore removed and neutrals + if (!validStates.has(i)) s.removed = true; + s.neighbors = s.neighbors.filter(n => validStates.has(n)); + + // find center + s.center = pack.burgs[s.capital].cell + ? pack.burgs[s.capital].cell // capital is the best bet + : pack.cells.state.findIndex(x => x === i); // otherwise use the first valid cell + }); + BurgsAndStates.getPoles(); + + // transfer provinces, mark provinces without land as removed. + stage("Porting provinces"); + const validProvinces = new Set(pack.cells.province); + pack.provinces = parentMap.pack.provinces; + // mark uneccesary provinces + pack.provinces.forEach((p, i) => { + if (!p || p.removed) return; + if (!validProvinces.has(i)) { + p.removed = true; + return; + } + const newCenters = forwardMap[p.center]; + p.center = newCenters.length ? newCenters[0] : pack.cells.province.findIndex(x => x === i); + }); + Provinces.getPoles(); + + stage("Regenerating routes network"); + regenerateRoutes(); + + Rivers.specify(); + Features.specify(); + + stage("Porting military"); + for (const s of pack.states) { + if (!s.military) continue; + for (const m of s.military) { + [m.x, m.y] = projection(m.x, m.y); + [m.bx, m.by] = projection(m.bx, m.by); + const cc = forwardMap[m.cell]; + m.cell = cc && cc.length ? cc[0] : null; + } + s.military = s.military.filter(m => m.cell).map((m, i) => ({...m, i})); + } + + stage("Copying markers"); + for (const m of pack.markers) { + const [x, y] = projection(m.x, m.y); + if (!inMap(x, y)) { + Markers.deleteMarker(m.i); + } else { + m.x = x; + m.y = y; + m.cell = findCell(x, y); + if (options.lockMarkers) m.lock = true; + } + } + if (layerIsOn("toggleMarkers")) drawMarkers(); + + stage("Regenerating Zones"); + Zones.generate(); + Names.getMapName(); + stage("Restoring Notes"); + notes = parentMap.notes; + stage("Submap done"); + + WARN && console.warn(`TOTAL: ${rn((performance.now() - timeStart) / 1000, 2)}s`); + showStatistics(); + INFO && console.groupEnd("Generated Map " + seed); + } + + /* find the nearest cell accepted by filter f *and* having at + * least one *neighbor* fulfilling filter g, up to cell-distance `max` + * returns [cellid, neighbor] tuple or undefined if no such cell. + * accepts coordinates (x, y) + */ + const findNearest = + (f, g, max = 3) => + (px, py) => { + const d2 = c => (px - pack.cells.p[c][0]) ** 2 + (py - pack.cells.p[c][0]) ** 2; + const startCell = findCell(px, py); + const tested = new Set([startCell]); // ignore analyzed cells + const kernel = (cs, level) => { + const [bestf, bestg] = cs.filter(f).reduce( + ([cf, cg], c) => { + const neighbors = pack.cells.c[c]; + const betterg = neighbors.filter(g).reduce((u, x) => (d2(x) < d2(u) ? x : u)); + if (cf === undefined) return [c, betterg]; + return betterg && d2(cf) < d2(c) ? [c, betterg] : [cf, cg]; + }, + [undefined, undefined] + ); + if (bestf && bestg) return [bestf, bestg]; + + // no suitable pair found, retry with next ring + const targets = new Set(cs.map(c => pack.cells.c[c]).flat()); + const ring = Array.from(targets).filter(nc => !tested.has(nc)); + if (level >= max || !ring.length) return [undefined, undefined]; + ring.forEach(c => tested.add(c)); + return kernel(ring, level + 1); + }; + const pair = kernel([startCell], 1); + return pair; + }; + + function copyBurgs(parentMap, projection, options) { + const cells = pack.cells; + pack.burgs = parentMap.pack.burgs; + + // remap burgs to the best new cell + pack.burgs.forEach((b, id) => { + if (id == 0) return; // skip empty city of neturals + [b.x, b.y] = projection(b.x, b.y); + b.population = b.population * options.scale; // adjust for populationRate change + + // disable out-of-map (removed) burgs + if (!inMap(b.x, b.y)) { + b.removed = true; + b.cell = null; + return; + } + + const cityCell = findCell(b.x, b.y); + let searchFunc; + const isFreeLand = c => cells.t[c] === 1 && !cells.burg[c]; + const nearCoast = c => cells.t[c] === -1; + + // check if we need to relocate the burg + if (cells.burg[cityCell]) + // already occupied + searchFunc = findNearest(isFreeLand, _ => true, 3); + + if (isWater(pack, cityCell) || b.port) + // burg is in water or port + searchFunc = findNearest(isFreeLand, nearCoast, 6); + + if (searchFunc) { + const [newCell, neighbor] = searchFunc(b.x, b.y); + if (!newCell) { + WARN && console.warn(`Can not relocate Burg: ${b.name} sunk and destroyed. :-(`); + b.cell = null; + b.removed = true; + return; + } + + [b.x, b.y] = b.port ? getCloseToEdgePoint(newCell, neighbor) : cells.p[newCell]; + if (b.port) b.port = cells.f[neighbor]; // copy feature number + b.cell = newCell; + if (b.port && !isWater(pack, neighbor)) console.error("betrayal! negihbor must be water!", b); + } else { + b.cell = cityCell; + } + if (b.i && !b.lock) b.lock = options.lockBurgs; + cells.burg[b.cell] = id; + }); + } + + function getCloseToEdgePoint(cell1, cell2) { + const {cells, vertices} = pack; + + const [x0, y0] = cells.p[cell1]; + + const commonVertices = cells.v[cell1].filter(vertex => vertices.c[vertex].some(cell => cell === cell2)); + const [x1, y1] = vertices.p[commonVertices[0]]; + const [x2, y2] = vertices.p[commonVertices[1]]; + const xEdge = (x1 + x2) / 2; + const yEdge = (y1 + y2) / 2; + + const x = rn(x0 + 0.95 * (xEdge - x0), 2); + const y = rn(y0 + 0.95 * (yEdge - y0), 2); + + return [x, y]; + } + + // export + return {resample, findNearest}; +})(); diff --git a/modules/ui/ai-generator.js b/modules/ui/ai-generator.js index c28efc8a..daa8cde6 100644 --- a/modules/ui/ai-generator.js +++ b/modules/ui/ai-generator.js @@ -3,7 +3,7 @@ const GPT_MODELS = ["gpt-4o-mini", "chatgpt-4o-latest", "gpt-4o", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo"]; const SYSTEM_MESSAGE = "I'm working on my fantasy map."; -function geneateWithAi(defaultPrompt, onApply) { +function generateWithAi(defaultPrompt, onApply) { updateValues(); $("#aiGenerator").dialog({ @@ -26,13 +26,14 @@ function geneateWithAi(defaultPrompt, onApply) { } }); - if (modules.geneateWithAi) return; - modules.geneateWithAi = true; + if (modules.generateWithAi) return; + modules.generateWithAi = true; function updateValues() { byId("aiGeneratorResult").value = ""; byId("aiGeneratorPrompt").value = defaultPrompt; byId("aiGeneratorKey").value = localStorage.getItem("fmg-ai-kl") || ""; + byId("aiGeneratorTemperature").value = localStorage.getItem("fmg-ai-temperature") || "1.2"; const select = byId("aiGeneratorModel"); select.options.length = 0; @@ -52,6 +53,12 @@ function geneateWithAi(defaultPrompt, onApply) { const prompt = byId("aiGeneratorPrompt").value; if (!prompt) return tip("Please enter a prompt", true, "error", 4000); + const temperature = parseFloat(byId("aiGeneratorTemperature").value); + if (isNaN(temperature) || temperature < 0 || temperature > 2) { + return tip("Temperature must be a number between 0 and 2", true, "error", 4000); + } + localStorage.setItem("fmg-ai-temperature", temperature.toString()); + try { button.disabled = true; const resultArea = byId("aiGeneratorResult"); @@ -70,7 +77,7 @@ function geneateWithAi(defaultPrompt, onApply) { {role: "system", content: SYSTEM_MESSAGE}, {role: "user", content: prompt} ], - temperature: 1.2, + temperature: temperature, stream: true // Enable streaming }) }); diff --git a/modules/ui/notes-editor.js b/modules/ui/notes-editor.js index 378cec3c..2b5d1c79 100644 --- a/modules/ui/notes-editor.js +++ b/modules/ui/notes-editor.js @@ -160,7 +160,7 @@ function editNotes(id, name) { } }; - geneateWithAi(prompt, onApply); + generateWithAi(prompt, onApply); } function downloadLegends() { diff --git a/modules/ui/style.js b/modules/ui/style.js index 49d1d8fb..3df79026 100644 --- a/modules/ui/style.js +++ b/modules/ui/style.js @@ -116,20 +116,20 @@ function selectStyleElement() { if ( [ "armies", - "routes", - "lakes", "biomes", "borders", - "cults", - "relig", "cells", "coastline", - "prec", + "coordinates", + "cults", + "gridOverlay", "ice", "icons", - "coordinates", - "zones", - "gridOverlay" + "lakes", + "prec", + "relig", + "routes", + "zones" ].includes(styleElement) ) { styleStroke.style.display = "block"; @@ -140,7 +140,7 @@ function selectStyleElement() { // stroke dash if ( - ["routes", "borders", "temperature", "legend", "population", "coordinates", "zones", "gridOverlay"].includes( + ["borders", "cells", "coordinates", "gridOverlay", "legend", "population", "routes", "temperature", "zones"].includes( styleElement ) ) { @@ -788,7 +788,7 @@ styleShadowInput.on("input", function () { styleFontAdd.on("click", function () { addFontNameInput.value = ""; addFontURLInput.value = ""; - + $("#addFontDialog").dialog({ title: "Add custom font", width: "26em", diff --git a/modules/ui/zones-editor.js b/modules/ui/zones-editor.js index d575a544..85888c4e 100644 --- a/modules/ui/zones-editor.js +++ b/modules/ui/zones-editor.js @@ -1,7 +1,7 @@ "use strict"; function editZones() { - closeDialogs(); + closeDialogs("#zonesEditor, .stable"); if (!layerIsOn("toggleZones")) toggleZones(); const body = byId("zonesBodySection"); diff --git a/modules/zones-generator.js b/modules/zones-generator.js index 7e8ec94b..641a0784 100644 --- a/modules/zones-generator.js +++ b/modules/zones-generator.js @@ -209,11 +209,11 @@ window.Zones = (function () { const cost = []; const maxCells = rand(20, 40); - const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); - queue.queue({e: burg.cell, p: 0}); + const queue = new FlatQueue(); + queue.push({e: burg.cell, p: 0}, 0); while (queue.length) { - const next = queue.dequeue(); + const next = queue.pop(); if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); usedCells[next.e] = 1; @@ -224,7 +224,7 @@ window.Zones = (function () { if (!cost[nextCellId] || p < cost[nextCellId]) { cost[nextCellId] = p; - queue.queue({e: nextCellId, p}); + queue.push({e: nextCellId, p}, p); } }); } @@ -251,11 +251,11 @@ window.Zones = (function () { const cost = []; const maxCells = rand(5, 25); - const queue = new PriorityQueue({comparator: (a, b) => a.p - b.p}); - queue.queue({e: burg.cell, p: 0}); + const queue = new FlatQueue(); + queue.push({e: burg.cell, p: 0}, 0); while (queue.length) { - const next = queue.dequeue(); + const next = queue.pop(); if (cells.burg[next.e] || cells.pop[next.e]) cellsArray.push(next.e); usedCells[next.e] = 1; @@ -266,7 +266,7 @@ window.Zones = (function () { if (!cost[e] || p < cost[e]) { cost[e] = p; - queue.queue({e, p}); + queue.push({e, p}, p); } }); } diff --git a/utils/stringUtils.js b/utils/stringUtils.js index 6325d278..1027ee8f 100644 --- a/utils/stringUtils.js +++ b/utils/stringUtils.js @@ -56,3 +56,11 @@ JSON.isValid = str => { } return true; }; + +JSON.safeParse = str => { + try { + return JSON.parse(str); + } catch (e) { + return null; + } +}; diff --git a/versioning.js b/versioning.js index 076de37c..fa8da7ec 100644 --- a/versioning.js +++ b/versioning.js @@ -12,7 +12,8 @@ * * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 */ -const VERSION = "1.105.19"; + +const VERSION = "1.105.22"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); {