574 lines
22 KiB
JavaScript
574 lines
22 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");
|
|
}
|
|
}
|
|
|
|
// Three passes: a smooth continent field decides land and sea, a finer field
|
|
// roughens the coastline, and clusters of terrain 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 = {};
|
|
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 };
|
|
let value = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.noise);
|
|
value = this._applyFalloff(coords, value, wrapped);
|
|
let elevation = value;
|
|
if (this.config.coastNoise) {
|
|
const coast = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.coastNoise);
|
|
elevation += (this.config.coastRoughness || 0) * coast;
|
|
}
|
|
let iceNoise = 0;
|
|
if (this.config.iceNoise) {
|
|
iceNoise = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.iceNoise);
|
|
}
|
|
const tile = this._chooseTile(coords, elevation, iceNoise);
|
|
this.tiles[key(coords.x, coords.y)] = tile;
|
|
if (tile.terrainClass === "Land") this.landCells.push(coords);
|
|
}
|
|
}
|
|
this._removeSmallFeatures();
|
|
if (this.config.connectOceans !== false) this._connectOceans();
|
|
this._placeTerrainClusters();
|
|
this._computeLandmasses();
|
|
}
|
|
|
|
_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.config.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 [];
|
|
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());
|
|
const chosen = [];
|
|
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;
|
|
}
|
|
|
|
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 the same count.
|
|
const groups = this._capitalGroups(capitals);
|
|
let perCiv = citiesPerCiv;
|
|
for (const group of groups) {
|
|
group.layout = this._neutralCitySites(
|
|
capitals,
|
|
group.civs,
|
|
group.candidates,
|
|
group.civs.length * citiesPerCiv,
|
|
minDistance
|
|
);
|
|
perCiv = Math.min(perCiv, Math.floor(group.layout.length / group.civs.length));
|
|
}
|
|
if (perCiv <= 1) return capitalsOnly();
|
|
// The assignment pass emits the capitals too, so it is the only source of
|
|
// cities here.
|
|
const cities = [];
|
|
for (const group of groups) {
|
|
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 };
|
|
}
|