Files
Battle-for-Tismo/shared/map_generator.js
T

922 lines
37 KiB
JavaScript

// 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 };
}