// Builds the hex world from simplex noise and chooses settlement sites. The // output is a dictionary of tile data keyed by "x,y" plus the land cells; the // simulation works from that data alone and the browser rebuilds its view from // it, so both sides generate identical terrain from the same seed. import { SimplexNoise } from "./noise.js"; import { Random } from "./rng.js"; import { key, parseKey } from "./hex.js"; import { TERRAIN_TILES } from "./data.js"; const TAU = Math.PI * 2; // Hard floor on the spacing between any two cities; cities are spread from a // neutral layout and then handed to the players in clusters. const CITY_MIN_DISTANCE = 5; function smoothstep(edge0, edge1, x) { const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); return t * t * (3 - 2 * t); } export class MapGenerator { constructor(config, topology) { this.config = config; this.topology = topology; this.landCells = []; this.tiles = {}; this._landmassSize = new Map(); this._palette = buildPalette(); if (!this._palette.plains || !this._palette.sea) { // A catalogue without land or sea cannot tile the map; fail with a clear // message instead of a null dereference deep in generate(). throw new Error("TERRAIN_TILES must define at least one Land and one Sea tile"); } } // Continents are seeded domes: a handful of smooth peaks spread over the map, // bent by detail noise into a ragged coast. The water level is not fixed; it // is lowered in steps until exactly `minContinents` landmasses remain, which // is what decides how much of the domes is above water. Terrain clusters are // then scattered over the land independently of the continent shape. generate(seed) { const noise = new SimplexNoise(seed); const halfX = Math.floor(this.config.mapSize.x / 2); const halfY = Math.floor(this.config.mapSize.y / 2); const wrapped = this.topology.isCylindrical(); const radius = this.config.mapSize.x / TAU; this.seed = seed; this.landCells = []; this.tiles = {}; const detail = this._detailField(noise, wrapped, radius); const centers = this._domeCenters(seed); const domeRadius = this._domeRadius(); const elevations = new Map(); const ice = new Set(); const iceNoise = new Map(); let minElevation = Infinity; let maxElevation = -Infinity; for (let row = 0; row < this.config.mapSize.y; row++) { for (let column = 0; column < this.config.mapSize.x; column++) { const coords = { x: column - halfX, y: row - halfY }; const k = key(coords.x, coords.y); let value = this._domeValue(coords, centers, domeRadius); if (detail) value += (this.config.detailAmplitude || 0) * detail.get(k); if (this.config.coastNoise) { const coast = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.coastNoise); value += (this.config.coastRoughness || 0) * coast; } if (this.config.falloffEnabled) value = this._applyFalloff(coords, value, wrapped); let polarNoise = 0; if (this.config.iceNoise) { polarNoise = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.iceNoise); } if (this._isPolar(coords, polarNoise)) ice.add(k); iceNoise.set(k, polarNoise); elevations.set(k, value); if (value < minElevation) minElevation = value; if (value > maxElevation) maxElevation = value; } } this.seaLevel = this._chooseSeaLevel(elevations, ice, minElevation, maxElevation); for (let row = 0; row < this.config.mapSize.y; row++) { for (let column = 0; column < this.config.mapSize.x; column++) { const coords = { x: column - halfX, y: row - halfY }; const k = key(coords.x, coords.y); const tile = this._chooseTile(coords, elevations.get(k), iceNoise.get(k)); this.tiles[k] = tile; if (tile.terrainClass === "Land") this.landCells.push(coords); } } this._removeSmallFeatures(); if (this.config.connectOceans !== false) this._connectOceans(); this._placeSmallIslands(seed, iceNoise); // Sweep up any speck left behind, including the slivers the ocean channels // may have cut and any island the noise thinned below the small-island size. this._removeSmallFeatures(); this._placeTerrainClusters(); this._computeLandmasses(); } // How many small islands this map gets, scaled by area so the small test // fixtures, which are a fraction of the reference map, get none. Never more // than there are nations, so every island ends up with a nation on it. _smallIslandCount() { const base = this.config.smallIslandCount || 0; const area = this.config.mapSize.x * this.config.mapSize.y; let count = Math.round((base * area) / (100 * 100)); const nations = this.config.nationCount; if (Number.isFinite(nations)) count = Math.min(count, Math.max(0, nations)); return count; } // Stamps a few noise-edged blobs of land into open water. They are placed // after the continents have settled, so they can never join one or affect the // water search, and every candidate sits far enough from land that the whole // blob stays clear of the coast. Each blob is sized well below // `minCapitalLandmass`, so it reads as an island and hosts no capital. _placeSmallIslands(seed, iceNoise) { const count = this._smallIslandCount(); if (count <= 0) return; const target = Math.max(4, Math.round(this.config.smallIslandSize || 30)); const radius = Math.max(2, Math.round(Math.sqrt(target / Math.PI))); // The rim may reach a couple of tiles past `radius`, so candidates keep that // much water between the blob and the nearest coast. const distance = this._seaDistanceToLand(radius + 2); const candidates = []; for (const k in this.tiles) { if (this.tiles[k].terrainClass !== "Sea") continue; const d = distance.get(k); if (d !== undefined && d <= radius + 2) continue; candidates.push(parseKey(k)); } if (candidates.length === 0) return; const rng = new Random((seed ^ 0x27d4eb2f) >>> 0); const centers = [candidates[rng.range(0, candidates.length - 1)]]; while (centers.length < count) { let best = null; let bestDistance = -1; for (const coords of candidates) { let nearest = Infinity; for (const other of centers) { const d = this.topology.tileDistance(coords, other); if (d < nearest) nearest = d; } if (nearest > bestDistance) { bestDistance = nearest; best = coords; } } // Stop rather than crowd islands together when the water is small. if (!best || bestDistance <= radius * 2) break; centers.push(best); } const plains = this._palette.plains; const tundra = this._palette.tundra; // The rim is cut by a fine noise field rather than by the smooth detail // field: the latter barely changes across a few tiles, which left the // islands as dice. A noise that varies within the footprint gives ragged, // lobed coasts. const edgeConfig = this.config.smallIslandNoise; const edgeNoise = new SimplexNoise((seed ^ 0x51ed270b) >>> 0); const wrapped = this.topology.isCylindrical(); const halfX = Math.floor(this.config.mapSize.x / 2); const cylinder = this.config.mapSize.x / TAU; for (const center of centers) { for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const coords = { x: center.x + dx, y: center.y + dy }; const k = key(coords.x, coords.y); const tile = this.tiles[k]; if (!tile || tile.terrainClass !== "Sea") continue; const d = this.topology.tileDistance(center, coords); const noise = edgeConfig ? this._sampleNoise(edgeNoise, coords.x + halfX, coords, wrapped, cylinder, edgeConfig) : 0; const irregularity = (noise + 1) / 2; // The rim wanders about a tile either way of `radius`, which is // enough to push it across the next hex ring and break the dice while // still keeping every island under the capital landmass size. if (d > radius + (irregularity - 0.5) * 1.8) continue; const polar = iceNoise ? iceNoise.get(k) || 0 : 0; this.tiles[k] = tundra && this._isTundra(coords, polar) ? tundra : plains; } } } this.smallIslandCenters = centers; this._rebuildLandCells(); } // Distance from every sea tile to the nearest non-sea tile (land or ice), up // to `maxDistance`; sea tiles beyond that are simply absent from the map. _seaDistanceToLand(maxDistance) { const distance = new Map(); let frontier = []; for (const k in this.tiles) { if (this.tiles[k].terrainClass !== "Sea") { distance.set(k, 0); frontier.push(parseKey(k)); } } let depth = 0; while (frontier.length > 0 && depth < maxDistance) { const next = []; for (const coords of frontier) { for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { const nk = key(neighbour.x, neighbour.y); if (distance.has(nk) || !this.tiles[nk]) continue; if (this.tiles[nk].terrainClass !== "Sea") continue; distance.set(nk, depth + 1); next.push(neighbour); } } frontier = next; depth += 1; } return distance; } // The number of continents the map is built around, and so how many domes to // scatter. Always at least one, so a map always has land. _continentCount() { return Math.max(1, Math.round(this.config.minContinents || 1)); } // Radius in tiles: the configured fraction of the shorter map side, so the // domes keep their proportions when the tests use a small map. _domeRadius() { const shorter = Math.min(this.config.mapSize.x, this.config.mapSize.y); return Math.max(2, (this.config.domeRadius || 0.26) * shorter); } // Picks `_continentCount()` dome centres by farthest-point sampling, so they // end up spread over the map instead of clustered. Working from the wrapped // hex distance keeps them spread across the cylindrical seam too. Centres are // held away from the poles so a peak is never hidden under the ice sheets. _domeCenters(seed) { const count = this._continentCount(); const rng = new Random((seed ^ 0x9e3779b9) >>> 0); const halfX = Math.floor(this.config.mapSize.x / 2); const halfY = Math.floor(this.config.mapSize.y / 2); const margin = Math.max(1, Math.round((this.config.domePolarMargin || 0) * halfY)); const candidates = []; for (let y = -halfY + margin; y < halfY - margin; y++) { for (let x = -halfX; x < halfX; x++) candidates.push({ x, y }); } if (candidates.length === 0) return []; const centers = [candidates[rng.range(0, candidates.length - 1)]]; while (centers.length < count) { let best = null; let bestDistance = -1; for (const coords of candidates) { let nearest = Infinity; for (const other of centers) { const d = this.topology.tileDistance(coords, other); if (d < nearest) nearest = d; } if (nearest > bestDistance) { bestDistance = nearest; best = coords; } } centers.push(best); } return centers; } // The height of the tallest dome at `coords`: a smooth exponential bump under // each centre, with the peaks all reaching 1 so no continent dominates. _domeValue(coords, centers, domeRadius) { let value = 0; for (const center of centers) { const d = this.topology.tileDistance(coords, center); const bump = Math.exp(-(d * d) / (domeRadius * domeRadius)); if (bump > value) value = bump; } return value; } // The detail field that bends the dome edge into a ragged coast. It is // rescaled to [-1, 1] so the wanted roughness holds on any map size. _detailField(noise, wrapped, radius) { const base = this.config.detailNoise; if (!base) return null; const width = Math.max(1, this.config.mapSize.x); const config = { ...base, frequency: (base.cycles || 1) / width }; const halfX = Math.floor(this.config.mapSize.x / 2); const halfY = Math.floor(this.config.mapSize.y / 2); const raw = new Map(); let min = Infinity; let max = -Infinity; for (let row = 0; row < this.config.mapSize.y; row++) { for (let column = 0; column < this.config.mapSize.x; column++) { const coords = { x: column - halfX, y: row - halfY }; const value = this._sampleNoise(noise, column, coords, wrapped, radius, config); raw.set(key(coords.x, coords.y), value); if (value < min) min = value; if (value > max) max = value; } } const span = max - min; const field = new Map(); for (const [k, value] of raw) { field.set(k, span > 1e-6 ? ((value - min) / span) * 2 - 1 : 0); } return field; } // Lowers the water from the highest peak in `seaLevelStep` steps and keeps // the lowest level that still leaves exactly the wanted number of continents. // A continent is a connected body of land at least `minContinentFraction` of // the map, so lone speckle cannot count. Returns the configured `seaLevel` // only when no level gives an exact match. _chooseSeaLevel(elevations, ice, minElevation, maxElevation) { const target = this._continentCount(); const step = Math.max(1e-4, this.config.seaLevelStep || 0.05); const area = this.config.mapSize.x * this.config.mapSize.y; const threshold = Math.max(1, Math.round(area * (this.config.minContinentFraction || 0))); const maxLand = area * (this.config.maxLandFraction || 1); let chosen = this.config.seaLevel || 0; let found = false; let fallback = null; for (let level = maxElevation; level >= minElevation - 1e-9; level -= step) { const { continents: count, land } = this._countContinents(elevations, ice, level, threshold); // Letting the water fall further would flood the map with land. if (land > maxLand) break; if (count === target) { chosen = level; found = true; continue; } // Domes have started merging, so dropping further only loses land. if (found && count < target) break; if (!found) { const difference = Math.abs(count - target); if (!fallback || difference < fallback.difference) fallback = { level, difference }; } } return found ? chosen : fallback ? fallback.level : chosen; } // How many connected bodies of land at least `threshold` tiles sit above // `level`, plus the total land area at that level. Polar ice counts as solid // ground here: it is not land, but it divides the map just the same. _countContinents(elevations, ice, level, threshold) { const seen = new Set(); let continents = 0; let land = 0; for (const k of elevations.keys()) { if (seen.has(k) || ice.has(k) || elevations.get(k) < level) continue; seen.add(k); const frontier = [parseKey(k)]; let size = 0; while (frontier.length > 0) { const coords = frontier.pop(); size += 1; for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { const nk = key(neighbour.x, neighbour.y); if (seen.has(nk) || ice.has(nk)) continue; const value = elevations.get(nk); if (value === undefined || value < level) continue; seen.add(nk); frontier.push(neighbour); } } land += size; if (size >= threshold) continents += 1; } return { continents, land }; } _sampleNoise(noise, column, coords, wrapped, radius, config) { if (!wrapped) { return noise.fbm(coords.x, coords.y, undefined, config); } const angle = (TAU * column) / this.config.mapSize.x; return noise.fbm(Math.cos(angle) * radius, Math.sin(angle) * radius, coords.y, config); } _applyFalloff(coords, value, wrapped) { if (!this.config.falloffEnabled) return value; const halfX = this.config.mapSize.x * 0.5; const halfY = this.config.mapSize.y * 0.5; if (halfX <= 0 || halfY <= 0) return value; const normalized = wrapped ? Math.abs(coords.y / halfY) : Math.hypot(coords.x / halfX, coords.y / halfY); const fade = smoothstep(this.config.falloffStart, 1.0, normalized); return value - fade * this.config.falloffStrength; } _isPolar(coords, iceNoise = 0) { const half = this.config.mapSize.y / 2; if (half <= 0) return false; const jitter = Math.min(1, Math.max(-1, iceNoise)); const latitude = this.config.iceLatitude + (this.config.iceRoughness || 0) * jitter; return Math.abs(coords.y) / half >= latitude; } _chooseTile(coords, elevation, iceNoise) { const palette = this._palette; if (palette.ice && this._isPolar(coords, iceNoise)) return palette.ice; if (elevation < this.seaLevel) return palette.sea; // Tundra fringes the ice sheets; land starts as plains otherwise, and the // terrain clusters are painted over it later. if (palette.tundra && this._isTundra(coords, iceNoise)) return palette.tundra; return palette.plains; } // The tundra band just outside the ice, its edge wandering on the same noise // as the ice so the two meet in a ragged line. _isTundra(coords, iceNoise = 0) { const half = this.config.mapSize.y / 2; if (half <= 0) return false; const jitter = Math.min(1, Math.max(-1, iceNoise)); const latitude = this.config.tundraLatitude + (this.config.iceRoughness || 0) * jitter; return Math.abs(coords.y) / half >= latitude; } // Third pass: scatter clusters of each biome over the land. Clusters are // seeded from random land tiles and grown one tile at a time into an // irregular blob, in priority order (earlier entries win overlaps) and // clipped to the land, so terrain types have nothing to do with the // continent shape. _placeTerrainClusters() { const biomes = this.config.terrainBiomes; if (!biomes || biomes.length === 0 || this.landCells.length === 0) return; const rng = new Random(this.seed ^ 0x5bd1e995); for (const biome of biomes) { const tile = this._palette.byType[biome.terrainType]; if (!tile) continue; const size = Math.max(1, Math.round(biome.size || 1)); const count = Math.max( 1, Math.round((biome.coverage * this.landCells.length) / size) ); for (let i = 0; i < count; i++) { const center = this._pickPaintableCenter(rng); if (!center) continue; // Vary the size so the patches do not all look the same. const grown = Math.max(1, Math.round(size * (0.5 + rng.float()))); this._growCluster(center, grown, tile, rng); } } } // Draws a cluster centre, retrying a few times when the drawn tile has already // been claimed by a higher-priority biome, so a low-coverage biome is not // silently dropped just because its one seed landed on a forest. _pickPaintableCenter(rng) { for (let attempt = 0; attempt < 8; attempt++) { const center = this.landCells[rng.range(0, this.landCells.length - 1)]; if (this._isPaintable(center)) return center; } return null; } // Grows one irregular blob of `size` tiles from `center`. At every step it // paints a random tile on the blob's edge, so the outline wanders instead of // closing into a hexagon, while the interior fills in rather than leaving // holes. It stops at the coast and at tiles already claimed by a // higher-priority biome. _growCluster(center, size, tile, rng) { if (!this._isPaintable(center)) return; const frontier = []; const queued = new Set(); const startKey = key(center.x, center.y); queued.add(startKey); this.tiles[startKey] = tile; this._queueClusterNeighbours(center, frontier, queued); let painted = 1; while (painted < size && frontier.length > 0) { const index = rng.range(0, frontier.length - 1); const coords = frontier[index]; frontier[index] = frontier[frontier.length - 1]; frontier.pop(); if (!this._isPaintable(coords)) continue; this.tiles[key(coords.x, coords.y)] = tile; painted += 1; this._queueClusterNeighbours(coords, frontier, queued); } } _queueClusterNeighbours(coords, frontier, queued) { for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { const nk = key(neighbour.x, neighbour.y); if (queued.has(nk) || !this._isPaintable(neighbour)) continue; queued.add(nk); frontier.push(neighbour); } } _isPaintable(coords) { return this._isLand(coords) && this.tiles[key(coords.x, coords.y)] === this._palette.plains; } pickCapitals(count, rng) { if (count <= 0 || this.landCells.length === 0) return []; // A nation on every small island comes first, so no island is left empty. const chosen = this._islandCapitalSites(count); if (chosen.length >= count) return chosen; let candidates = this._capitalCandidates(false); if (candidates.length === 0) candidates = this._capitalCandidates(true); if (candidates.length === 0) candidates = this.landCells.slice(); const shuffled = rng.shuffle(candidates.slice()); for (const coords of shuffled) { if (chosen.length >= count) break; if (this._isFarEnough(coords, chosen, this.config.minCapitalDistance)) chosen.push(coords); } if (chosen.length < count) { for (const coords of shuffled) { if (chosen.length >= count) break; if (!chosen.some((c) => c.x === coords.x && c.y === coords.y)) chosen.push(coords); } } return chosen; } // One capital site per small island, up to `count`: the island's tile nearest // the centre that touches the sea, so an island nation can always build a // port. Islands the feature cleanup removed are skipped. _islandCapitalSites(count) { const centers = this.smallIslandCenters || []; const chosen = []; for (const center of centers) { if (chosen.length >= count) break; const site = this._coastalLandNear(center); if (site) chosen.push(site); } return chosen; } // The land tile nearest `center` (across connected land) that touches a sea // tile, or null when `center` is not land. _coastalLandNear(center) { if (!this._isLand(center)) return null; const seen = new Set([key(center.x, center.y)]); let frontier = [center]; while (frontier.length > 0) { const next = []; for (const coords of frontier) { if (this._isCoastal(coords)) return { x: coords.x, y: coords.y }; for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { const nk = key(neighbour.x, neighbour.y); if (seen.has(nk) || !this._isLand(neighbour)) continue; seen.add(nk); next.push(neighbour); } } frontier = next; } return null; } _isCoastal(coords) { for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { const tile = this.tiles[key(neighbour.x, neighbour.y)]; if (tile && tile.terrainClass === "Sea") return true; } return false; } pickCities(capitals, citiesPerCiv, minSpacing) { const capitalsOnly = () => capitals.map((coords, civIndex) => ({ coords, civIndex, isCapital: true })); if (capitals.length === 0) return []; if (citiesPerCiv <= 1) return capitalsOnly(); // Lay out neutral cities first, evenly spread and never closer than the // minimum distance, then cluster them onto the players so each civ ends up // with a compact block of cities instead of scattered exclaves. const minDistance = Math.max(minSpacing || 0, CITY_MIN_DISTANCE); // Work one landmass at a time: each coast is given as many neutral sites as // its capitals need, so the cluster pass can hand every civ its share. const groups = this._capitalGroups(capitals); const cities = []; for (const group of groups) { group.layout = this._neutralCitySites( capitals, group.civs, group.candidates, group.civs.length * citiesPerCiv, minDistance ); // Each landmass keeps as many cities per civ as it has room for: a tiny // island nation starts with little more than its capital, while the // continent nations get their full spread. Doing this per landmass keeps // a cramped island from starving the continents, which is why there is no // longer one shared count. const perCiv = Math.min(citiesPerCiv, Math.floor(group.layout.length / group.civs.length)); if (perCiv <= 1) { for (const civ of group.civs) { cities.push({ coords: capitals[civ], civIndex: civ, isCapital: true }); } continue; } this._assignCitySites(capitals, group, perCiv, cities); } return cities; } // Groups the capitals by the connected landmass they stand on, with that // landmass's tiles as the pool cities may be drawn from, so no city ends up // stranded on an island no capital can reach. _capitalGroups(capitals) { const components = this._components("Land"); const indexByKey = new Map(); for (let i = 0; i < components.length; i++) { for (const coords of components[i]) indexByKey.set(key(coords.x, coords.y), i); } const groups = components.map((candidates) => ({ civs: [], candidates, layout: [] })); for (let civ = 0; civ < capitals.length; civ++) { const group = indexByKey.get(key(capitals[civ].x, capitals[civ].y)); if (group !== undefined) groups[group].civs.push(civ); } return groups.filter((group) => group.civs.length > 0); } // Neutral layout: the capitals plus the land tile furthest from every city so // far, until there is one site per slot. Farthest-point sampling keeps them // evenly spread and at least `minDistance` apart. _neutralCitySites(capitals, civs, candidates, count, minDistance) { const sites = civs.map((civ) => ({ coords: capitals[civ], civIndex: civ, isCapital: true })); if (sites.length >= count) return sites; const centers = civs.map((civ) => capitals[civ]); const distance = candidates.map((coords) => this._nearestDistance(coords, centers)); while (sites.length < count) { let best = -1; let bestDistance = -1; for (let i = 0; i < candidates.length; i++) { if (distance[i] < minDistance) continue; if (distance[i] > bestDistance) { bestDistance = distance[i]; best = i; } } if (best < 0) break; const coords = candidates[best]; sites.push({ coords, civIndex: -1, isCapital: false }); for (let i = 0; i < candidates.length; i++) { const d = this.topology.tileDistance(candidates[i], coords); if (d < distance[i]) distance[i] = d; } } return sites; } // Hands a landmass's neutral layout to its players: each capital keeps its // site, and every other site goes to the nearest capital by land that still // has room. Working by distance keeps each civ's cities together; a site is // dropped rather than sent far away if every nearby civ is full. _assignCitySites(capitals, group, perCiv, cities) { const layout = group.layout; const civs = group.civs; const distances = civs.map((civ) => this._landDistances(capitals[civ])); const capacity = civs.map(() => perCiv - 1); const owner = layout.map((site) => (site.isCapital ? site.civIndex : -1)); const pairs = []; for (let site = 0; site < layout.length; site++) { if (layout[site].isCapital) continue; const k = key(layout[site].coords.x, layout[site].coords.y); for (let index = 0; index < civs.length; index++) { const distance = distances[index].get(k); if (distance === undefined) continue; pairs.push({ site, index, distance }); } } pairs.sort((a, b) => a.distance - b.distance || a.site - b.site || a.index - b.index); for (const pair of pairs) { if (owner[pair.site] !== -1 || capacity[pair.index] <= 0) continue; owner[pair.site] = civs[pair.index]; capacity[pair.index] -= 1; } for (let site = 0; site < layout.length; site++) { if (owner[site] < 0) continue; cities.push({ coords: layout[site].coords, civIndex: owner[site], isCapital: layout[site].isCapital, }); } } // Land-path distance from one capital to every tile it can reach, so the // assignment measures closeness along the coast rather than as the crow flies // across a bay. _landDistances(capital) { const distances = new Map(); distances.set(key(capital.x, capital.y), 0); let frontier = [capital]; while (frontier.length > 0) { const next = []; for (const coords of frontier) { const distance = distances.get(key(coords.x, coords.y)); for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { const nk = key(neighbour.x, neighbour.y); if (distances.has(nk) || !this._isLand(neighbour)) continue; distances.set(nk, distance + 1); next.push(neighbour); } } frontier = next; } return distances; } _nearestDistance(coords, centers) { let nearest = Infinity; for (const center of centers) { const distance = this.topology.tileDistance(coords, center); if (distance < nearest) nearest = distance; } return nearest; } _capitalCandidates(ignoreMargin) { const limit = { x: Math.floor(this.config.mapSize.x / 2) - this.config.capitalMargin, y: Math.floor(this.config.mapSize.y / 2) - this.config.capitalMargin, }; let largest = 0; for (const coords of this.landCells) { largest = Math.max(largest, this._landmassSize.get(key(coords.x, coords.y)) || 0); } if (largest <= 0) return []; const threshold = Math.min(Math.max(this.config.minCapitalLandmass, 1), largest); const candidates = []; for (const coords of this.landCells) { if ((this._landmassSize.get(key(coords.x, coords.y)) || 0) < threshold) continue; if (!ignoreMargin && this._outsideMargin(coords, limit)) continue; candidates.push(coords); } return candidates; } _outsideMargin(coords, limit) { if (Math.abs(coords.y) > limit.y) return true; return !this.topology.isCylindrical() && Math.abs(coords.x) > limit.x; } _isFarEnough(coords, chosen, minDistance) { for (const other of chosen) { if (this.topology.tileDistance(coords, other) < minDistance) return false; } return true; } // Joins every separate sea into one connected ocean, so no stretch of water // is cut off from the rest. The largest sea is the trunk; a 0-1 search spreads // out from it where crossing open water is free and crossing land costs the // one tile that would have to be dug. Each sea is then linked by carving the // cheapest channel to water already reachable, which keeps the new canals // short. The result is deterministic, so the browser rebuilds the same coast. _connectOceans() { const components = this._seaComponents(); if (components.length <= 1) return; let mainIndex = 0; for (let i = 1; i < components.length; i++) { if (components[i].length > components[mainIndex].length) mainIndex = i; } const dist = new Map(); const parent = new Map(); const buckets = []; const place = (k, d, from) => { if (dist.has(k) && dist.get(k) <= d) return; dist.set(k, d); parent.set(k, from); (buckets[d] || (buckets[d] = [])).push(k); }; for (const coords of components[mainIndex]) place(key(coords.x, coords.y), 0, undefined); for (let d = 0; d < buckets.length; d++) { const bucket = buckets[d]; if (!bucket) continue; while (bucket.length > 0) { const k = bucket.pop(); if (dist.get(k) !== d) continue; const coords = parseKey(k); for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { const nk = key(neighbour.x, neighbour.y); const tile = this.tiles[nk]; if (!tile) continue; place(nk, d + (tile.terrainClass === "Sea" ? 0 : 1), k); } } } // Dig each sea's cheapest route back to the ocean. The routes may share // tiles, which is harmless. for (let i = 0; i < components.length; i++) { if (i === mainIndex) continue; let endpoint = null; let best = Infinity; for (const coords of components[i]) { const k = key(coords.x, coords.y); const d = dist.get(k); if (d !== undefined && d < best) { best = d; endpoint = k; } } let k = endpoint; while (k !== undefined) { if (this.tiles[k].terrainClass !== "Sea") this.tiles[k] = this._palette.sea; k = parent.get(k); } } this._rebuildLandCells(); } // Every sea tile grouped into its connected body, in scan order. _seaComponents() { return this._components("Sea"); } // Every tile of the given terrain class grouped into connected bodies, in // scan order. Both the ocean search and the small-feature cleanup use it. _components(terrainClass) { const seen = new Set(); const components = []; for (const k in this.tiles) { if (seen.has(k) || this.tiles[k].terrainClass !== terrainClass) continue; const component = []; let frontier = [parseKey(k)]; seen.add(k); while (frontier.length > 0) { const next = []; for (const coords of frontier) { component.push(coords); for (const neighbour of this.topology.neighbours(coords.x, coords.y)) { const nk = key(neighbour.x, neighbour.y); if (seen.has(nk)) continue; const tile = this.tiles[nk]; if (!tile || tile.terrainClass !== terrainClass) continue; seen.add(nk); next.push(neighbour); } } frontier = next; } components.push(component); } return components; } // Smooths away the speckle: islands below the configured fraction of the map // become sea, and seas below it become land. Small maps round the thresholds // to one tile, which leaves them untouched. _removeSmallFeatures() { const area = this.config.mapSize.x * this.config.mapSize.y; const minIsland = Math.max(1, Math.round(area * (this.config.minIslandFraction || 0))); const minLake = Math.max(1, Math.round(area * (this.config.minLakeFraction || 0))); if (minIsland <= 1 && minLake <= 1) return; let changed = false; if (minIsland > 1) { for (const component of this._components("Land")) { if (component.length >= minIsland) continue; for (const coords of component) this.tiles[key(coords.x, coords.y)] = this._palette.sea; changed = true; } } if (minLake > 1) { const fill = this._palette.plains; for (const component of this._components("Sea")) { if (component.length >= minLake) continue; for (const coords of component) this.tiles[key(coords.x, coords.y)] = fill; changed = true; } } if (changed) this._rebuildLandCells(); } // Recomputes the land cells after carving bridges through land. _rebuildLandCells() { this.landCells = []; for (const k in this.tiles) { if (this.tiles[k].terrainClass === "Land") this.landCells.push(parseKey(k)); } } _computeLandmasses() { this._landmassSize = new Map(); const landSet = new Set(this.landCells.map((c) => key(c.x, c.y))); const visited = new Set(); for (const start of this.landCells) { if (visited.has(key(start.x, start.y))) continue; const component = []; const frontier = [start]; visited.add(key(start.x, start.y)); while (frontier.length > 0) { const current = frontier.pop(); component.push(current); for (const neighbour of this.topology.neighbours(current.x, current.y)) { const k = key(neighbour.x, neighbour.y); if (landSet.has(k) && !visited.has(k)) { visited.add(k); frontier.push(neighbour); } } } for (const cell of component) { this._landmassSize.set(key(cell.x, cell.y), component.length); } } } _isLand(coords) { const tile = this.tiles[key(coords.x, coords.y)]; return !!tile && tile.terrainClass === "Land"; } } function buildPalette() { const landTiles = TERRAIN_TILES.filter((t) => t.terrainClass === "Land"); const byType = {}; for (const tile of landTiles) { // The first entry of a type is the canonical one (there is more than one // plain "Land" sprite only through the atlas, not the model). if (!byType[tile.terrainType]) byType[tile.terrainType] = tile; } const plains = byType.Land || landTiles[0] || null; const sea = TERRAIN_TILES.find((t) => t.terrainClass === "Sea") || null; const ice = TERRAIN_TILES.find((t) => t.terrainClass === "Ice") || null; const tundra = byType.Tundra || null; return { plains, byType, sea, ice, tundra }; }