World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land. Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built. Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
1082 lines
43 KiB
JavaScript
1082 lines
43 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();
|
|
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);
|
|
if (this._isPolar(coords)) ice.add(k);
|
|
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));
|
|
this.tiles[k] = tile;
|
|
if (tile.terrainClass === "Land") this.landCells.push(coords);
|
|
}
|
|
}
|
|
this._removeSmallFeatures();
|
|
if (this.config.connectOceans !== false) this._connectOceans();
|
|
this._placeSmallIslands(seed);
|
|
// 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) {
|
|
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;
|
|
this.tiles[k] = tundra && this._isTundra(coords) ? 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;
|
|
}
|
|
|
|
// Finds the water level whose land is closest to `landFraction` of the
|
|
// ice-free tiles; the caller then joins the ocean into one body, so the map
|
|
// keeps a single connected sea. Land only grows as the water falls, so the
|
|
// level is found by bisection instead of sweeping every step (each probe is a
|
|
// full flood fill, and a sweep made generation needlessly slow). Only the
|
|
// fallback `seaLevel` is used when no fraction is set.
|
|
_chooseSeaLevel(elevations, ice, minElevation, maxElevation) {
|
|
const fraction = this.config.landFraction;
|
|
const iceFree = elevations.size - ice.size;
|
|
if (!(fraction > 0) || iceFree <= 0) return this.config.seaLevel || 0;
|
|
// The small islands are stamped in after the water is chosen, so allow for
|
|
// the land they will add; otherwise the finished map overshoots the target
|
|
// by their area.
|
|
const islandLand =
|
|
this._smallIslandCount() *
|
|
Math.max(4, Math.round(this.config.smallIslandSize || 30)) *
|
|
0.9;
|
|
const targetLand = Math.max(0, fraction * iceFree - islandLand);
|
|
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 measure = (level) => {
|
|
const { land } = this._countContinents(elevations, ice, level, threshold);
|
|
return land - targetLand;
|
|
};
|
|
let best = { level: minElevation, difference: measure(minElevation) };
|
|
if (best.difference <= 0) return best.level;
|
|
let low = minElevation;
|
|
let high = maxElevation;
|
|
const range = Math.max(1e-6, high - low);
|
|
const iterations = Math.min(32, Math.ceil(Math.log2(range / step)) + 2);
|
|
for (let i = 0; i < iterations; i += 1) {
|
|
const level = (low + high) / 2;
|
|
const difference = measure(level);
|
|
if (Math.abs(difference) < Math.abs(best.difference)) best = { level, difference };
|
|
// Too much land means the water is too low: raise the level, and vice
|
|
// versa.
|
|
if (difference > 0) low = level;
|
|
else high = level;
|
|
}
|
|
return best.level;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// How many rows a tile sits from the nearer polar edge: 0 is the outermost
|
|
// row, growing toward the equator. The map is stored with row 0 at
|
|
// `-floor(height / 2)`, so this is the one place that maps a tile to its
|
|
// latitude band.
|
|
_rowsFromEdge(coords) {
|
|
const height = this.config.mapSize.y;
|
|
const row = coords.y + Math.floor(height / 2);
|
|
return Math.min(row, height - 1 - row);
|
|
}
|
|
|
|
// The configured ice rows are a cap; on a map far shorter than the reference
|
|
// a fixed three-row band would bury most of the world, so the bands shrink
|
|
// with the map and only the full-size game gets all three.
|
|
iceRowCount() {
|
|
return Math.min(
|
|
Math.max(0, this.config.iceRows || 0),
|
|
Math.max(0, Math.floor(this.config.mapSize.y / 10))
|
|
);
|
|
}
|
|
|
|
tundraRowCount() {
|
|
return Math.min(
|
|
Math.max(0, this.config.tundraRows || 0),
|
|
Math.max(0, Math.floor(this.config.mapSize.y / 10))
|
|
);
|
|
}
|
|
|
|
// The polar ice is exactly the `iceRows` rows closest to each edge.
|
|
_isPolar(coords) {
|
|
return this._rowsFromEdge(coords) < this.iceRowCount();
|
|
}
|
|
|
|
// The tundra is the `tundraRows` rows just outside the ice, so it never
|
|
// reaches further equatorward than that.
|
|
_isTundra(coords) {
|
|
const rows = this._rowsFromEdge(coords);
|
|
const ice = this.iceRowCount();
|
|
return rows >= ice && rows < ice + this.tundraRowCount();
|
|
}
|
|
|
|
_chooseTile(coords, elevation) {
|
|
const palette = this._palette;
|
|
if (palette.ice && this._isPolar(coords)) 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)) return palette.tundra;
|
|
return palette.plains;
|
|
}
|
|
|
|
// 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();
|
|
// Draw the candidates, then work from the best sites down: a strong
|
|
// preference for coastal plains, with the draw breaking ties, while the
|
|
// minimum distance still keeps the capitals spread out.
|
|
const shuffled = rng.shuffle(candidates.slice());
|
|
const preferred = shuffled
|
|
.map((coords) => ({ coords, score: this._siteScore(coords) }))
|
|
.sort((a, b) => b.score - a.score);
|
|
for (const { coords } of preferred) {
|
|
if (chosen.length >= count) break;
|
|
if (this._isFarEnough(coords, chosen, this.config.minCapitalDistance)) chosen.push(coords);
|
|
}
|
|
if (chosen.length < count) {
|
|
for (const { coords } of preferred) {
|
|
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;
|
|
}
|
|
|
|
// How strongly a tile attracts a city. A coastal plain is ideal: the port
|
|
// opens the sea lanes and the flat ground feeds the people. Tundra and
|
|
// desert are shunned, and a landlocked tile is worst of all because it can
|
|
// never reach the world market.
|
|
_siteScore(coords) {
|
|
const tile = this.tiles[key(coords.x, coords.y)];
|
|
if (!tile) return -Infinity;
|
|
let score = 0;
|
|
if (tile.terrainType === "Land") score += 3;
|
|
else if (tile.terrainType === "Tundra") score -= 5;
|
|
else if (tile.terrainType === "Desert") score -= 5;
|
|
else if (tile.terrainType === "Mountain") score -= 3;
|
|
else if (tile.terrainType === "Hills") score -= 2;
|
|
else if (tile.terrainType === "Forest") score -= 1;
|
|
score += this._isCoastal(coords) ? 8 : -10;
|
|
return score;
|
|
}
|
|
|
|
pickCities(capitals, citiesPerCiv, minSpacing) {
|
|
if (capitals.length === 0) return [];
|
|
const cities = [];
|
|
if (citiesPerCiv <= 1) {
|
|
for (let civIndex = 0; civIndex < capitals.length; civIndex++) {
|
|
cities.push({ coords: capitals[civIndex], civIndex, isCapital: true });
|
|
}
|
|
} else {
|
|
// 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);
|
|
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);
|
|
}
|
|
}
|
|
// Whatever the layout did, no land may be left unclaimed and no civ may be
|
|
// shut out of a landmass it holds by having only inland cities there.
|
|
this._fillEmptyLandmasses(cities, capitals);
|
|
this._guaranteeCoastalCities(cities);
|
|
return cities;
|
|
}
|
|
|
|
// Every landmass must hold at least one city, so no island is left
|
|
// unclaimed. A landmass the layout pass skipped gets a coastal city (so it
|
|
// can still reach world trade) belonging to the civilisation whose capital
|
|
// lies nearest.
|
|
_fillEmptyLandmasses(cities, capitals) {
|
|
const occupied = new Set(cities.map((site) => key(site.coords.x, site.coords.y)));
|
|
for (const component of this._components("Land")) {
|
|
let settled = false;
|
|
for (const coords of component) {
|
|
if (occupied.has(key(coords.x, coords.y))) {
|
|
settled = true;
|
|
break;
|
|
}
|
|
}
|
|
if (settled) continue;
|
|
// Prefer the shore; fall back to the best inland tile for the rare
|
|
// landmass ringed entirely by ice, so no land is left unclaimed.
|
|
const site = this._coastalSite(component, occupied, true);
|
|
if (!site) continue;
|
|
occupied.add(key(site.x, site.y));
|
|
cities.push({
|
|
coords: site,
|
|
civIndex: this._nearestCapitalCiv(site, capitals),
|
|
isCapital: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Every civilisation that holds land on a landmass must hold a coastal city
|
|
// there, so its ports open that island to the sea. When all of a civ's
|
|
// cities on a landmass are inland, one coastal site is added for it.
|
|
_guaranteeCoastalCities(cities) {
|
|
const components = this._components("Land");
|
|
const componentOf = new Map();
|
|
for (let i = 0; i < components.length; i++) {
|
|
for (const coords of components[i]) componentOf.set(key(coords.x, coords.y), i);
|
|
}
|
|
const coastal = new Map();
|
|
for (const site of cities) {
|
|
const index = componentOf.get(key(site.coords.x, site.coords.y));
|
|
if (index === undefined) continue;
|
|
const group = `${index}:${site.civIndex}`;
|
|
if (this._isCoastal(site.coords)) coastal.set(group, true);
|
|
else if (!coastal.has(group)) coastal.set(group, false);
|
|
}
|
|
const occupied = new Set(cities.map((site) => key(site.coords.x, site.coords.y)));
|
|
for (const [group, hasCoast] of coastal) {
|
|
if (hasCoast) continue;
|
|
const [index, civIndex] = group.split(":").map(Number);
|
|
const site = this._coastalSite(components[index], occupied);
|
|
if (!site) continue;
|
|
occupied.add(key(site.x, site.y));
|
|
cities.push({ coords: site, civIndex, isCapital: false });
|
|
}
|
|
}
|
|
|
|
// The best tile of `component` that no city holds yet: the highest
|
|
// `_siteScore`, so a coastal plain if there is one, ranked by `_siteScore`.
|
|
// With `allowInland` an inland tile may win too, for the rare landmass with
|
|
// no sea of its own.
|
|
_coastalSite(component, occupied, allowInland = false) {
|
|
let best = null;
|
|
let bestScore = -Infinity;
|
|
for (const coords of component) {
|
|
if (!allowInland && !this._isCoastal(coords)) continue;
|
|
const k = key(coords.x, coords.y);
|
|
if (occupied && occupied.has(k)) continue;
|
|
const score = this._siteScore(coords);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
best = coords;
|
|
}
|
|
}
|
|
return best ? { x: best.x, y: best.y } : null;
|
|
}
|
|
|
|
// The civilisation whose capital is nearest `coords`, used to hand an empty
|
|
// island to somebody when no capital sits on it.
|
|
_nearestCapitalCiv(coords, capitals) {
|
|
let civ = 0;
|
|
let best = Infinity;
|
|
for (let i = 0; i < capitals.length; i++) {
|
|
const distance = this.topology.tileDistance(coords, capitals[i]);
|
|
if (distance < best) {
|
|
best = distance;
|
|
civ = i;
|
|
}
|
|
}
|
|
return civ;
|
|
}
|
|
|
|
// 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 tiles that best combine spacing
|
|
// and site quality, until there is one site per slot. A tile's priority is
|
|
// its distance to the nearest chosen site plus a bonus for being a good city
|
|
// site (a coastal plain), so the layout spreads out while still leaning to
|
|
// the coast. Sites are never closer than `minDistance` to another.
|
|
_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));
|
|
const scores = candidates.map((coords) => this._siteScore(coords));
|
|
let minScore = Infinity;
|
|
let maxScore = -Infinity;
|
|
for (const score of scores) {
|
|
if (score < minScore) minScore = score;
|
|
if (score > maxScore) maxScore = score;
|
|
}
|
|
const span = maxScore - minScore;
|
|
const preference = scores.map((score) => (span > 1e-6 ? (score - minScore) / span : 0));
|
|
const bias = minDistance;
|
|
while (sites.length < count) {
|
|
let best = -1;
|
|
let bestPriority = -Infinity;
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
if (distance[i] < minDistance) continue;
|
|
const priority = distance[i] + bias * preference[i];
|
|
if (priority > bestPriority) {
|
|
bestPriority = priority;
|
|
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 };
|
|
}
|