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

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