1696 lines
56 KiB
Python
1696 lines
56 KiB
Python
"""
|
|
Map generator TypeScript assembly — builds MapGenerator.generated.ts content.
|
|
|
|
Each function emits a section of the generated TypeScript file, faithfully
|
|
translating the GDScript map generation pipeline (9-stage + hydrology) into
|
|
TypeScript that operates on axial-keyed GenMap during generation, then converts
|
|
to flat GridState (TileState[]) at the end.
|
|
|
|
Source GDScript files:
|
|
engine/src/generation/map_generator.gd
|
|
engine/src/generation/map_shape_seeds.gd
|
|
engine/src/generation/terrain_refiner.gd
|
|
engine/src/generation/wind_calculator.gd
|
|
engine/src/generation/hydrology.gd
|
|
engine/src/generation/hydrology_rivers.gd
|
|
engine/src/map/hex_utils.gd
|
|
"""
|
|
|
|
from lilith_gdscript_transpiler import PCG32_PREAMBLE
|
|
|
|
|
|
def _mg_build_full_output() -> str:
|
|
"""Build the complete MapGenerator.generated.ts content."""
|
|
parts: list[str] = [
|
|
_header(),
|
|
PCG32_PREAMBLE + "\n\n",
|
|
_constants(),
|
|
_hex_helpers(),
|
|
_tile_map_class(),
|
|
_water_check(),
|
|
_stage1_seeds(),
|
|
_stage2_growth(),
|
|
_stage3_normalize(),
|
|
_stage4_sea_level(),
|
|
_stage5_relief(),
|
|
_stage6_temperature(),
|
|
_stage7_moisture(),
|
|
_stage8_patches(),
|
|
_stage9_wind(),
|
|
_quality(),
|
|
_hydrology(),
|
|
_render_metadata(),
|
|
_generate_function(),
|
|
]
|
|
return "".join(parts)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File header
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _header() -> str:
|
|
return """\
|
|
// AUTO-GENERATED from GDScript map generation pipeline — do not edit manually.
|
|
// Sources: engine/src/generation/map_generator.gd + map_shape_seeds.gd +
|
|
// terrain_refiner.gd + wind_calculator.gd + hydrology.gd + hydrology_rivers.gd
|
|
// engine/src/map/hex_utils.gd
|
|
// Regenerate: uv run tools/transpile-engine/transpile.py
|
|
|
|
import type { GridState, TileState, TerrainData } from './types'
|
|
import { idx, neighbors, axialToOffset, hashNoise, AXIAL_DIRECTIONS } from './HexGrid'
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _constants() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SIZE_TABLE: Record<string, { width: number; height: number; default_players: number; natural_wonders: number }> = {
|
|
duel: { width: 40, height: 24, default_players: 2, natural_wonders: 1 },
|
|
tiny: { width: 56, height: 36, default_players: 4, natural_wonders: 2 },
|
|
small: { width: 66, height: 42, default_players: 4, natural_wonders: 2 },
|
|
standard: { width: 80, height: 52, default_players: 8, natural_wonders: 3 },
|
|
large: { width: 104, height: 64, default_players: 10, natural_wonders: 4 },
|
|
huge: { width: 128, height: 80, default_players: 12, natural_wonders: 5 },
|
|
}
|
|
|
|
const DEFAULT_TYPE_DATA: Record<string, unknown> = {
|
|
ocean_percentage: { target: 0.40, variance: 0.05 },
|
|
continent_count: { min: 2, max: 4 },
|
|
terrain_fractions: {
|
|
enchanted_forest: 0.01, desert: 0.10, swamp: 0.07,
|
|
volcano: 0.02,
|
|
},
|
|
generation_params: {
|
|
num_landmass: 35, steepness: 0.20, prevailing_wind_direction: 0,
|
|
coastline_smoothing_iterations: 2, river_count_per_continent: 2,
|
|
river_source_min_distance: 4, start_position_min_distance: 10,
|
|
},
|
|
}
|
|
|
|
const WIND_DEFAULTS: Record<string, number> = {
|
|
wind_band_polar_cut: 0.15,
|
|
wind_band_polar_front_cut: 0.30,
|
|
wind_band_ferrel_cut: 0.50,
|
|
wind_band_subtropical_cut: 0.60,
|
|
wind_band_hadley_cut: 0.85,
|
|
wind_speed_polar: 0.6,
|
|
wind_speed_polar_front: 0.3,
|
|
wind_speed_ferrel: 0.8,
|
|
wind_speed_subtropical: 0.3,
|
|
wind_speed_hadley: 0.7,
|
|
wind_speed_itcz: 0.3,
|
|
wind_friction_land: 0.7,
|
|
wind_friction_mountain_downwind: 0.1,
|
|
}
|
|
|
|
const OPPOSITE_DIR: readonly number[] = [3, 4, 5, 0, 1, 2]
|
|
const WATER_TERRAINS: readonly string[] = ['ocean', 'coast', 'lake', 'inland_sea']
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hex coordinate helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _hex_helpers() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Hex coordinate helpers (from hex_utils.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Vec2i { x: number; y: number }
|
|
|
|
function offsetToAxial(col: number, row: number): Vec2i {
|
|
const q = col
|
|
const r = row - Math.trunc((col - (col & 1)) / 2)
|
|
return { x: q, y: r }
|
|
}
|
|
|
|
function axialToOffsetCoords(q: number, r: number): { col: number; row: number } {
|
|
const col = q
|
|
const row = r + Math.trunc((q - (q & 1)) / 2)
|
|
return { col, row }
|
|
}
|
|
|
|
function axialNeighbors(ax: Vec2i): Vec2i[] {
|
|
return AXIAL_DIRECTIONS.map(([dq, dr]) => ({ x: ax.x + dq, y: ax.y + dr }))
|
|
}
|
|
|
|
function axialKey(ax: Vec2i): string {
|
|
return `${ax.x},${ax.y}`
|
|
}
|
|
|
|
function hexRing(center: Vec2i, radius: number): Vec2i[] {
|
|
if (radius <= 0) return []
|
|
const results: Vec2i[] = []
|
|
let current: Vec2i = {
|
|
x: center.x + AXIAL_DIRECTIONS[4][0] * radius,
|
|
y: center.y + AXIAL_DIRECTIONS[4][1] * radius,
|
|
}
|
|
for (let d = 0; d < 6; d++) {
|
|
for (let s = 0; s < radius; s++) {
|
|
results.push(current)
|
|
current = { x: current.x + AXIAL_DIRECTIONS[d][0], y: current.y + AXIAL_DIRECTIONS[d][1] }
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
function hexSpiral(center: Vec2i, radius: number): Vec2i[] {
|
|
const results: Vec2i[] = [center]
|
|
for (let r = 1; r <= radius; r++) {
|
|
for (const pos of hexRing(center, r)) results.push(pos)
|
|
}
|
|
return results
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal generation tile + map
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _tile_map_class() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Internal generation tile + map (converts to GridState at the end)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface GenTile {
|
|
axial: Vec2i
|
|
col: number
|
|
row: number
|
|
terrain_id: string
|
|
elevation: number
|
|
moisture: number
|
|
temperature: number
|
|
is_coastal: boolean
|
|
quality: number
|
|
quality_progress: number
|
|
wind_direction: number
|
|
wind_speed: number
|
|
river_edges: number[]
|
|
river_flow: Record<string, number>
|
|
flow_accumulation: number
|
|
lake_id: number
|
|
river_source_type: string
|
|
variation_index: number
|
|
}
|
|
|
|
function newGenTile(axial: Vec2i, col: number, row: number): GenTile {
|
|
return {
|
|
axial, col, row,
|
|
terrain_id: '',
|
|
elevation: 0.0,
|
|
moisture: 0.0,
|
|
temperature: 0.0,
|
|
is_coastal: false,
|
|
quality: 2,
|
|
quality_progress: 0,
|
|
wind_direction: 0,
|
|
wind_speed: 0.5,
|
|
river_edges: [],
|
|
river_flow: {},
|
|
flow_accumulation: 0.0,
|
|
lake_id: -1,
|
|
river_source_type: '',
|
|
variation_index: 0,
|
|
}
|
|
}
|
|
|
|
class GenMap {
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly tiles: Map<string, GenTile> = new Map()
|
|
|
|
constructor(width: number, height: number) {
|
|
this.width = width
|
|
this.height = height
|
|
}
|
|
|
|
setTile(ax: Vec2i, tile: GenTile): void {
|
|
this.tiles.set(axialKey(ax), tile)
|
|
}
|
|
|
|
getTile(ax: Vec2i): GenTile | undefined {
|
|
return this.tiles.get(axialKey(ax))
|
|
}
|
|
|
|
hasTile(ax: Vec2i): boolean {
|
|
return this.tiles.has(axialKey(ax))
|
|
}
|
|
|
|
getTilesByTerrain(terrainId: string): Vec2i[] {
|
|
const result: Vec2i[] = []
|
|
for (const tile of this.tiles.values()) {
|
|
if (tile.terrain_id === terrainId) result.push(tile.axial)
|
|
}
|
|
return result
|
|
}
|
|
|
|
toGridState(): GridState {
|
|
const n = this.width * this.height
|
|
const tiles: TileState[] = new Array(n)
|
|
for (let row = 0; row < this.height; row++) {
|
|
for (let col = 0; col < this.width; col++) {
|
|
const ax = offsetToAxial(col, row)
|
|
const gt = this.getTile(ax)
|
|
const i = idx(col, row, this.width)
|
|
tiles[i] = {
|
|
col, row,
|
|
temperature: gt?.temperature ?? 0.0,
|
|
moisture: gt?.moisture ?? 0.0,
|
|
elevation: gt?.elevation ?? 0.0,
|
|
terrain_id: gt?.terrain_id ?? 'ocean',
|
|
wind_direction: gt?.wind_direction ?? 0,
|
|
wind_speed: gt?.wind_speed ?? 0.5,
|
|
quality: gt?.quality ?? 2,
|
|
quality_progress: gt?.quality_progress ?? 0,
|
|
river_edges: gt?.river_edges ?? [],
|
|
flow_accumulation: gt?.flow_accumulation ?? 0.0,
|
|
original_terrain_id: '',
|
|
ley_line_count: 0,
|
|
ley_school: '',
|
|
reef_health: 1.0,
|
|
magic_heat_delta: 0.0,
|
|
magic_moisture_delta: 0.0,
|
|
is_natural_wonder: false,
|
|
wonder_anchor_strength: 0.0,
|
|
wonder_anchor_school: 'none',
|
|
wonder_anchor_schools: [],
|
|
wonder_tier: 0,
|
|
river_source_type: gt?.river_source_type || undefined,
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
tiles,
|
|
width: this.width,
|
|
height: this.height,
|
|
global_avg_temp: 0.5,
|
|
ocean_dead_fraction: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _water_check() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function isWaterTerrain(terrainId: string): boolean {
|
|
return terrainId === 'ocean' || terrainId === 'coast'
|
|
}
|
|
|
|
function isWaterTerrainHydro(terrainId: string): boolean {
|
|
return WATER_TERRAINS.includes(terrainId)
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 1: Region seed placement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage1_seeds() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 1: Region seed placement (map_generator.gd + map_shape_seeds.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Region {
|
|
center: Vec2i
|
|
elevation: number
|
|
edge: boolean
|
|
}
|
|
|
|
function placeRegionSeedsShaped(
|
|
gm: GenMap, genParams: Record<string, number>, typeId: string, rng: PCG32,
|
|
): Region[] {
|
|
const regions: Region[] = []
|
|
const w = gm.width
|
|
const h = gm.height
|
|
|
|
// Edge seeds every 5 tiles along all four borders (always ocean frame)
|
|
for (let col = 0; col < w; col += 5) {
|
|
for (const rowVal of [0, h - 1]) {
|
|
regions.push({ center: offsetToAxial(col, rowVal), elevation: 0, edge: true })
|
|
}
|
|
}
|
|
for (let row = 0; row < h; row += 5) {
|
|
for (const colVal of [0, w - 1]) {
|
|
regions.push({ center: offsetToAxial(colVal, row), elevation: 0, edge: true })
|
|
}
|
|
}
|
|
|
|
const numInterior = genParams['num_landmass'] ?? (20 + Math.floor(15.0 * Math.sqrt((w * h) / 2772.0)))
|
|
const cx = w / 2.0
|
|
const cy = h / 2.0
|
|
|
|
placeSeedsForShape(regions, w, h, cx, cy, numInterior, typeId, genParams, rng)
|
|
return regions
|
|
}
|
|
|
|
function placeSeedsForShape(
|
|
regions: Region[], w: number, h: number, cx: number, cy: number,
|
|
numInterior: number, typeId: string, genParams: Record<string, number>, rng: PCG32,
|
|
): void {
|
|
const seedShape = String(genParams['seed_shape'] ?? '')
|
|
switch (seedShape) {
|
|
case 'two_clusters':
|
|
placeSeedsTwoClusters(regions, w, h, cx, cy, numInterior, genParams, rng); break
|
|
case 'cross':
|
|
placeSeedsCross(regions, w, h, cx, cy, numInterior, genParams, rng); break
|
|
case 'plus':
|
|
placeSeedsPlus(regions, w, h, cx, cy, numInterior, genParams, rng); break
|
|
default:
|
|
placeSeedsDefault(regions, w, h, cx, cy, numInterior, typeId, rng); break
|
|
}
|
|
}
|
|
|
|
function placeSeedsDefault(
|
|
regions: Region[], w: number, h: number, cx: number, cy: number,
|
|
numInterior: number, typeId: string, rng: PCG32,
|
|
): void {
|
|
const isPangaea = typeId === 'pangaea'
|
|
const stdDev = Math.min(w, h) * 0.25
|
|
for (let i = 0; i < numInterior; i++) {
|
|
let col: number, row: number
|
|
if (isPangaea) {
|
|
col = Math.min(Math.max(cx + randfn(rng, 0.0, stdDev), 1.0), w - 2.0)
|
|
row = Math.min(Math.max(cy + randfn(rng, 0.0, stdDev), 1.0), h - 2.0)
|
|
} else {
|
|
col = rng.randfRange(1.0, w - 2.0)
|
|
row = rng.randfRange(1.0, h - 2.0)
|
|
}
|
|
regions.push({
|
|
center: offsetToAxial(Math.round(col), Math.round(row)),
|
|
elevation: rng.randiRange(0, 1000),
|
|
edge: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
/** Gaussian normal distribution via Box-Muller (matches Godot's randfn). */
|
|
function randfn(rng: PCG32, mean: number, deviation: number): number {
|
|
const u1 = Math.max(1e-10, rng.randf())
|
|
const u2 = rng.randf()
|
|
const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2)
|
|
return mean + z0 * deviation
|
|
}
|
|
|
|
function placeSeedsTwoClusters(
|
|
regions: Region[], w: number, h: number, _cx: number, cy: number,
|
|
numInterior: number, genParams: Record<string, number>, rng: PCG32,
|
|
): void {
|
|
const clusterStd = Math.min(w, h) * (genParams['cluster_std_dev'] ?? 0.18)
|
|
const leftCx = w * 0.25
|
|
const rightCx = w * 0.75
|
|
for (let i = 0; i < numInterior; i++) {
|
|
const targetCx = i % 2 === 0 ? leftCx : rightCx
|
|
const col = Math.min(Math.max(targetCx + randfn(rng, 0.0, clusterStd), 1.0), w - 2.0)
|
|
const row = Math.min(Math.max(cy + randfn(rng, 0.0, clusterStd), 1.0), h - 2.0)
|
|
regions.push({
|
|
center: offsetToAxial(Math.round(col), Math.round(row)),
|
|
elevation: rng.randiRange(0, 1000),
|
|
edge: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
function placeSeedsCross(
|
|
regions: Region[], w: number, h: number, cx: number, cy: number,
|
|
numInterior: number, genParams: Record<string, number>, rng: PCG32,
|
|
): void {
|
|
const armW = genParams['arm_width_fraction'] ?? 0.18
|
|
const centerR = genParams['center_radius_fraction'] ?? 0.15
|
|
const centerRadius = Math.min(w, h) * centerR
|
|
let placed = 0
|
|
const maxAttempts = numInterior * 10
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
if (placed >= numInterior) break
|
|
const col = rng.randfRange(1.0, w - 2.0)
|
|
const row = rng.randfRange(1.0, h - 2.0)
|
|
const nx = col / w
|
|
const ny = row / h
|
|
const dcx = Math.abs(col - cx)
|
|
const dcy = Math.abs(row - cy)
|
|
const distCenter = Math.sqrt(dcx * dcx + dcy * dcy)
|
|
const onDiag1 = Math.abs(nx - ny)
|
|
const onDiag2 = Math.abs(nx - (1.0 - ny))
|
|
if (distCenter < centerRadius || onDiag1 < armW || onDiag2 < armW) {
|
|
regions.push({
|
|
center: offsetToAxial(Math.round(col), Math.round(row)),
|
|
elevation: rng.randiRange(0, 1000),
|
|
edge: false,
|
|
})
|
|
placed++
|
|
}
|
|
}
|
|
}
|
|
|
|
function placeSeedsPlus(
|
|
regions: Region[], w: number, h: number, cx: number, cy: number,
|
|
numInterior: number, genParams: Record<string, number>, rng: PCG32,
|
|
): void {
|
|
const armW = genParams['arm_width_fraction'] ?? 0.18
|
|
const centerR = genParams['center_radius_fraction'] ?? 0.15
|
|
const centerRadius = Math.min(w, h) * centerR
|
|
const armHalfWPx = w * armW * 0.5
|
|
const armHalfHPx = h * armW * 0.5
|
|
let placed = 0
|
|
const maxAttempts = numInterior * 10
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
if (placed >= numInterior) break
|
|
const col = rng.randfRange(1.0, w - 2.0)
|
|
const row = rng.randfRange(1.0, h - 2.0)
|
|
const dcx = Math.abs(col - cx)
|
|
const dcy = Math.abs(row - cy)
|
|
const distCenter = Math.sqrt(dcx * dcx + dcy * dcy)
|
|
const onHorizontal = dcy < armHalfHPx
|
|
const onVertical = dcx < armHalfWPx
|
|
if (distCenter < centerRadius || onHorizontal || onVertical) {
|
|
regions.push({
|
|
center: offsetToAxial(Math.round(col), Math.round(row)),
|
|
elevation: rng.randiRange(0, 1000),
|
|
edge: false,
|
|
})
|
|
placed++
|
|
}
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 2: Region growth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage2_growth() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 2: Region growth — hex Voronoi BFS (map_generator.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function growRegions(
|
|
gm: GenMap, regions: Region[], elevation: Map<string, number>, rng: PCG32,
|
|
): void {
|
|
const claimed = new Set<string>()
|
|
const queue: Array<{ pos: Vec2i; ridx: number }> = []
|
|
|
|
for (let i = 0; i < regions.length; i++) {
|
|
const center = regions[i].center
|
|
const key = axialKey(center)
|
|
if (!gm.hasTile(center) || claimed.has(key)) continue
|
|
claimed.add(key)
|
|
setRegionElevation(gm, center, regions[i].elevation, elevation)
|
|
queue.push({ pos: center, ridx: i })
|
|
}
|
|
|
|
let head = 0
|
|
while (head < queue.length) {
|
|
const entry = queue[head++]
|
|
const ridx = entry.ridx
|
|
for (const nb of axialNeighbors(entry.pos)) {
|
|
const key = axialKey(nb)
|
|
if (!gm.hasTile(nb) || claimed.has(key)) continue
|
|
claimed.add(key)
|
|
setRegionElevation(gm, nb, regions[ridx].elevation, elevation)
|
|
queue.push({ pos: nb, ridx })
|
|
}
|
|
}
|
|
|
|
// Elevation fuzz +/-2 to break hard region boundaries
|
|
for (const tile of gm.tiles.values()) {
|
|
const key = axialKey(tile.axial)
|
|
const fuzz = rng.randiRange(-2, 2)
|
|
const prev = elevation.get(key) ?? 0.0
|
|
elevation.set(key, prev + fuzz)
|
|
tile.elevation = prev + fuzz
|
|
}
|
|
}
|
|
|
|
function setRegionElevation(
|
|
gm: GenMap, axial: Vec2i, baseElevation: number, elevation: Map<string, number>,
|
|
): void {
|
|
const tile = gm.getTile(axial)
|
|
if (!tile) return
|
|
tile.elevation = baseElevation
|
|
elevation.set(axialKey(axial), baseElevation)
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 3: Normalise elevation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage3_normalize() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 3: Normalise elevation to [0, 1] (map_generator.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function normalizeElevation(gm: GenMap, elevation: Map<string, number>): void {
|
|
const allElevs: number[] = []
|
|
for (const tile of gm.tiles.values()) {
|
|
allElevs.push(elevation.get(axialKey(tile.axial)) ?? 0.0)
|
|
}
|
|
allElevs.sort((a, b) => a - b)
|
|
|
|
const n = allElevs.length
|
|
if (n <= 1) return
|
|
|
|
for (const tile of gm.tiles.values()) {
|
|
const key = axialKey(tile.axial)
|
|
const elev = elevation.get(key) ?? 0.0
|
|
const rank = bsearch(allElevs, elev)
|
|
const normalised = rank / (n - 1)
|
|
elevation.set(key, normalised)
|
|
tile.elevation = normalised
|
|
}
|
|
}
|
|
|
|
function bsearch(sorted: number[], value: number): number {
|
|
let lo = 0, hi = sorted.length
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >>> 1
|
|
if (sorted[mid] < value) lo = mid + 1
|
|
else hi = mid
|
|
}
|
|
return lo
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 4: Sea level + coastline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage4_sea_level() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 4: Sea level assignment + coastline smoothing (map_generator.gd + terrain_refiner.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function assignSeaLevel(
|
|
gm: GenMap, oceanTarget: number, genParams: Record<string, number>,
|
|
elevation: Map<string, number>,
|
|
): void {
|
|
const allElevs: number[] = []
|
|
for (const tile of gm.tiles.values()) {
|
|
allElevs.push(elevation.get(axialKey(tile.axial)) ?? 0.0)
|
|
}
|
|
allElevs.sort((a, b) => a - b)
|
|
|
|
const seaIdx = Math.min(
|
|
Math.max(Math.round(oceanTarget * allElevs.length), 0), allElevs.length - 1,
|
|
)
|
|
const seaLevel = allElevs[seaIdx]
|
|
|
|
for (const tile of gm.tiles.values()) {
|
|
const elev = elevation.get(axialKey(tile.axial)) ?? 0.0
|
|
tile.terrain_id = elev < seaLevel ? 'ocean' : 'land'
|
|
}
|
|
|
|
smoothCoastlines(gm, genParams)
|
|
assignCoastTiles(gm)
|
|
}
|
|
|
|
function smoothCoastlines(gm: GenMap, params: Record<string, number>): void {
|
|
const iterations = params['coastline_smoothing_iterations'] ?? 2
|
|
for (let pass = 0; pass < iterations; pass++) {
|
|
const changes: Array<{ pos: Vec2i; terrain: string }> = []
|
|
for (const tile of gm.tiles.values()) {
|
|
let landCount = 0
|
|
let neighborCount = 0
|
|
for (const [dq, dr] of AXIAL_DIRECTIONS) {
|
|
const nb = gm.getTile({ x: tile.axial.x + dq, y: tile.axial.y + dr })
|
|
if (!nb) continue
|
|
neighborCount++
|
|
if (!isWaterTerrain(nb.terrain_id)) landCount++
|
|
}
|
|
const waterCount = neighborCount - landCount
|
|
if (isWaterTerrain(tile.terrain_id) && landCount >= 5) {
|
|
changes.push({ pos: tile.axial, terrain: 'grassland' })
|
|
} else if (!isWaterTerrain(tile.terrain_id) && waterCount >= 5) {
|
|
changes.push({ pos: tile.axial, terrain: 'ocean' })
|
|
}
|
|
}
|
|
for (const change of changes) {
|
|
const tile = gm.getTile(change.pos)
|
|
if (tile) tile.terrain_id = change.terrain
|
|
}
|
|
}
|
|
}
|
|
|
|
function assignCoastTiles(gm: GenMap): void {
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id === 'ocean') {
|
|
let hasLandNeighbor = false
|
|
for (const [dq, dr] of AXIAL_DIRECTIONS) {
|
|
const nb = gm.getTile({ x: tile.axial.x + dq, y: tile.axial.y + dr })
|
|
if (nb && !isWaterTerrain(nb.terrain_id)) { hasLandNeighbor = true; break }
|
|
}
|
|
if (hasLandNeighbor) tile.terrain_id = 'coast'
|
|
} else if (!isWaterTerrain(tile.terrain_id)) {
|
|
for (const [dq, dr] of AXIAL_DIRECTIONS) {
|
|
const nb = gm.getTile({ x: tile.axial.x + dq, y: tile.axial.y + dr })
|
|
if (nb && isWaterTerrain(nb.terrain_id)) { tile.is_coastal = true; break }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 5: Tectonic relief
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage5_relief() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 5: Tectonic relief (terrain_refiner.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function placeTectonicRelief(
|
|
gm: GenMap, elevation: Map<string, number>,
|
|
genParams: Record<string, number>, rng: PCG32,
|
|
): void {
|
|
const steepness = genParams['steepness'] ?? 0.20
|
|
|
|
// Compute local average elevation (radius 3) for each land tile
|
|
const localAvg = new Map<string, number>()
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id === 'ocean' || tile.terrain_id === 'coast') continue
|
|
const nearby = hexSpiral(tile.axial, 3)
|
|
let total = 0.0, count = 0
|
|
for (const nb of nearby) {
|
|
const e = elevation.get(axialKey(nb))
|
|
if (e !== undefined) { total += e; count++ }
|
|
}
|
|
localAvg.set(axialKey(tile.axial), count > 0 ? total / count : 0.5)
|
|
}
|
|
|
|
const landTiles: Vec2i[] = []
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id !== 'ocean' && tile.terrain_id !== 'coast') {
|
|
landTiles.push(tile.axial)
|
|
}
|
|
}
|
|
if (landTiles.length === 0) return
|
|
|
|
const mountainTiles: Vec2i[] = []
|
|
const hillTiles: Vec2i[] = []
|
|
|
|
for (const axial of landTiles) {
|
|
const tile = gm.getTile(axial)!
|
|
const elev = elevation.get(axialKey(axial)) ?? 0.0
|
|
const avg = localAvg.get(axialKey(axial)) ?? 0.5
|
|
|
|
let adjOcean = false
|
|
for (const nb of axialNeighbors(axial)) {
|
|
const nbTile = gm.getTile(nb)
|
|
if (nbTile && (nbTile.terrain_id === 'ocean' || nbTile.terrain_id === 'coast')) {
|
|
adjOcean = true; break
|
|
}
|
|
}
|
|
|
|
if (!adjOcean && elev > avg * 1.20) {
|
|
tile.terrain_id = 'mountains'
|
|
mountainTiles.push(axial)
|
|
} else if (!adjOcean && elev > avg * 1.10) {
|
|
tile.terrain_id = 'hills'
|
|
hillTiles.push(axial)
|
|
} else if (rng.randf() < 0.40) {
|
|
tile.terrain_id = 'hills'
|
|
hillTiles.push(axial)
|
|
}
|
|
}
|
|
|
|
// Enforce steepness cap
|
|
const totalRelief = mountainTiles.length + hillTiles.length
|
|
const cap = Math.round(landTiles.length * steepness)
|
|
if (totalRelief > cap) {
|
|
hillTiles.sort((a, b) =>
|
|
(elevation.get(axialKey(a)) ?? 0) - (elevation.get(axialKey(b)) ?? 0),
|
|
)
|
|
const excess = totalRelief - cap
|
|
for (let i = 0; i < Math.min(excess, hillTiles.length); i++) {
|
|
const tile = gm.getTile(hillTiles[i])
|
|
if (tile) tile.terrain_id = 'land'
|
|
}
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 6: Temperature
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage6_temperature() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 6: Temperature map (map_generator.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function computeTemperature(
|
|
gm: GenMap, elevation: Map<string, number>, temperature: Map<string, number>,
|
|
): void {
|
|
const centerY = gm.height / 2.0
|
|
for (const tile of gm.tiles.values()) {
|
|
const row = tile.row
|
|
const baseTemp = 1.0 - Math.abs((row - centerY) / centerY)
|
|
const elev = elevation.get(axialKey(tile.axial)) ?? 0.0
|
|
const temp = Math.min(1.0, Math.max(0.0,
|
|
baseTemp - elev * 0.3 + (tile.is_coastal ? 0.15 : 0.0),
|
|
))
|
|
temperature.set(axialKey(tile.axial), temp)
|
|
tile.temperature = temp
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 7: Moisture
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage7_moisture() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 7: Moisture map (map_generator.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function computeMoisture(
|
|
gm: GenMap, elevation: Map<string, number>,
|
|
moisture: Map<string, number>, rng: PCG32,
|
|
): void {
|
|
// BFS from all ocean/coast tiles simultaneously
|
|
const dist = new Map<string, number>()
|
|
const queue: Vec2i[] = []
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id === 'ocean' || tile.terrain_id === 'coast') {
|
|
dist.set(axialKey(tile.axial), 0)
|
|
queue.push(tile.axial)
|
|
}
|
|
}
|
|
|
|
let head = 0
|
|
while (head < queue.length) {
|
|
const pos = queue[head++]
|
|
const d = dist.get(axialKey(pos))!
|
|
if (d >= 10) continue
|
|
for (const nb of axialNeighbors(pos)) {
|
|
const key = axialKey(nb)
|
|
if (gm.hasTile(nb) && !dist.has(key)) {
|
|
dist.set(key, d + 1)
|
|
queue.push(nb)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Noise via hash (replaces FastNoiseLite)
|
|
const noiseSeed = rng.randi()
|
|
for (const tile of gm.tiles.values()) {
|
|
const key = axialKey(tile.axial)
|
|
const base = 1.0 - (dist.get(key) ?? 10) / 10.0
|
|
const localV = (hashNoise(tile.axial.x * 0.08, tile.axial.y * 0.08, noiseSeed) + 1.0) / 2.0
|
|
const moist = Math.min(1.0, Math.max(0.0, base + localV * 0.2))
|
|
moisture.set(key, moist)
|
|
tile.moisture = moist
|
|
}
|
|
|
|
// Rain shadow from mountains
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id !== 'mountains') continue
|
|
const wind = 0 // base direction before quality pass
|
|
const [dq, dr] = AXIAL_DIRECTIONS[wind]
|
|
for (let r = 1; r < 3; r++) {
|
|
const shadow: Vec2i = { x: tile.axial.x + dq * r, y: tile.axial.y + dr * r }
|
|
const sKey = axialKey(shadow)
|
|
if (!moisture.has(sKey)) continue
|
|
const val = Math.min(1.0, Math.max(0.0, (moisture.get(sKey) ?? 0) - 0.3))
|
|
moisture.set(sKey, val)
|
|
const st = gm.getTile(shadow)
|
|
if (st) st.moisture = val
|
|
}
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 8: Terrain patches
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage8_patches() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 8: Terrain patch expansion (terrain_refiner.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function assignTerrainPatches(
|
|
gm: GenMap, typeData: Record<string, unknown>,
|
|
elevation: Map<string, number>, moisture: Map<string, number>,
|
|
temperature: Map<string, number>, rng: PCG32,
|
|
): void {
|
|
const defaultFractions: Record<string, number> = {
|
|
forest: 0.12, jungle: 0.06, boreal_forest: 0.05,
|
|
enchanted_forest: 0.01, desert: 0.10, swamp: 0.07,
|
|
volcano: 0.02,
|
|
}
|
|
const fractions = (typeData['terrain_fractions'] ?? defaultFractions) as Record<string, number>
|
|
|
|
let landCount = 0
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id === 'land') landCount++
|
|
}
|
|
|
|
const order: string[] = [
|
|
'volcano', 'jungle', 'forest', 'boreal_forest', 'enchanted_forest',
|
|
'desert', 'swamp', 'tundra', 'snow', 'grassland',
|
|
]
|
|
|
|
for (const terrainId of order) {
|
|
let targetCount = 0
|
|
if (terrainId === 'tundra' || terrainId === 'snow') {
|
|
const isFrozen = terrainId === 'snow'
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id !== 'land') continue
|
|
const t = temperature.get(axialKey(tile.axial)) ?? 0.5
|
|
if (isFrozen && t < 0.10) targetCount++
|
|
else if (!isFrozen && t >= 0.10 && t < 0.25) targetCount++
|
|
}
|
|
} else if (terrainId === 'jungle' || terrainId === 'forest' || terrainId === 'boreal_forest') {
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id !== 'land') continue
|
|
const t = temperature.get(axialKey(tile.axial)) ?? 0.5
|
|
const m = moisture.get(axialKey(tile.axial)) ?? 0.5
|
|
if (terrainId === 'jungle' && t > 0.65 && m >= 0.35) targetCount++
|
|
else if (terrainId === 'forest' && t >= 0.25 && t <= 0.65 && m >= 0.35) targetCount++
|
|
else if (terrainId === 'boreal_forest' && t >= 0.10 && t < 0.25 && m >= 0.25) targetCount++
|
|
}
|
|
targetCount = Math.round(targetCount * (fractions[terrainId] ?? 0.0))
|
|
} else if (terrainId === 'enchanted_forest') {
|
|
let forestFamilyCount = 0
|
|
for (const tile of gm.tiles.values()) {
|
|
const tid = tile.terrain_id
|
|
if (tid === 'forest' || tid === 'jungle' || tid === 'boreal_forest') forestFamilyCount++
|
|
}
|
|
const baseCount = forestFamilyCount > 0 ? forestFamilyCount : landCount
|
|
targetCount = Math.round(baseCount * (fractions['enchanted_forest'] ?? 0.01))
|
|
} else if (terrainId === 'grassland') {
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id === 'land') targetCount++
|
|
}
|
|
} else if (terrainId === 'volcano') {
|
|
const mtCount = gm.getTilesByTerrain('mountains').length
|
|
targetCount = Math.round(mtCount * (fractions['volcano'] ?? 0.02))
|
|
} else {
|
|
targetCount = Math.round(landCount * (fractions[terrainId] ?? 0.0))
|
|
}
|
|
if (targetCount <= 0) continue
|
|
expandPatch(gm, terrainId, targetCount, elevation, moisture, temperature, rng)
|
|
}
|
|
|
|
// Convert any remaining unclassified 'land' tiles to 'plains'
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id === 'land') tile.terrain_id = 'plains'
|
|
}
|
|
}
|
|
|
|
function expandPatch(
|
|
gm: GenMap, terrainId: string, targetCount: number,
|
|
elevation: Map<string, number>, moisture: Map<string, number>,
|
|
temperature: Map<string, number>, rng: PCG32,
|
|
): void {
|
|
const eligible: Vec2i[] = []
|
|
const src = terrainId === 'volcano' ? 'mountains' : 'land'
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id === src && isEligible(tile.axial, terrainId, gm, elevation, moisture, temperature)) {
|
|
eligible.push(tile.axial)
|
|
}
|
|
}
|
|
if (eligible.length === 0) return
|
|
|
|
let placed = 0, attempts = 0
|
|
const maxAttempts = targetCount * 10
|
|
while (placed < targetCount && attempts < maxAttempts && eligible.length > 0) {
|
|
attempts++
|
|
const ei = rng.randiRange(0, eligible.length - 1)
|
|
const seedPos = eligible[ei]
|
|
const tile = gm.getTile(seedPos)
|
|
if (!tile || tile.terrain_id !== src) {
|
|
eligible.splice(ei, 1); continue
|
|
}
|
|
tile.terrain_id = terrainId
|
|
placed++
|
|
|
|
for (const nb of axialNeighbors(seedPos)) {
|
|
if (placed >= targetCount) break
|
|
const nbTile = gm.getTile(nb)
|
|
if (!nbTile || nbTile.terrain_id !== src) continue
|
|
if (!isEligible(nb, terrainId, gm, elevation, moisture, temperature)) continue
|
|
if (rng.randf() < 0.65) {
|
|
nbTile.terrain_id = terrainId
|
|
placed++
|
|
eligible.push(nb)
|
|
}
|
|
}
|
|
eligible.splice(ei, 1)
|
|
}
|
|
}
|
|
|
|
function isEligible(
|
|
axial: Vec2i, terrainId: string, gm: GenMap,
|
|
elevation: Map<string, number>, moisture: Map<string, number>,
|
|
temperature: Map<string, number>,
|
|
): boolean {
|
|
const t = temperature.get(axialKey(axial)) ?? 0.5
|
|
const m = moisture.get(axialKey(axial)) ?? 0.5
|
|
const e = elevation.get(axialKey(axial)) ?? 0.5
|
|
switch (terrainId) {
|
|
case 'volcano':
|
|
for (const nb of axialNeighbors(axial)) {
|
|
const nbTile = gm.getTile(nb)
|
|
if (nbTile && nbTile.terrain_id === 'mountains') return false
|
|
}
|
|
return true
|
|
case 'jungle': return t > 0.65 && m >= 0.35
|
|
case 'forest': return t >= 0.25 && t <= 0.65 && m >= 0.35
|
|
case 'boreal_forest': return t >= 0.10 && t < 0.25 && m >= 0.25
|
|
case 'enchanted_forest': return m >= 0.40
|
|
case 'desert': return t >= 0.25 && m < 0.25
|
|
case 'swamp': return t > 0.25 && m > 0.75 && e < 0.48
|
|
case 'tundra': return t >= 0.10 && t < 0.25
|
|
case 'snow': return t < 0.10
|
|
case 'grassland': return true
|
|
}
|
|
return true
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stage 9a: Wind map
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _stage9_wind() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Stage 9a: Wind map — 3-cell atmospheric model (wind_calculator.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function computeWindMap(gm: GenMap, windParams: Record<string, number>): void {
|
|
const params = { ...WIND_DEFAULTS, ...windParams }
|
|
const h = gm.height
|
|
const centerRow = h / 2.0
|
|
|
|
// Pre-compute per-row wind
|
|
const rowWind = new Map<number, [number, number]>()
|
|
for (let row = 0; row < h; row++) {
|
|
rowWind.set(row, bandWindForRow(row, h, centerRow, params))
|
|
}
|
|
|
|
// Pass 1: assign base direction and speed
|
|
for (const tile of gm.tiles.values()) {
|
|
const entry = rowWind.get(tile.row)!
|
|
tile.wind_direction = entry[0]
|
|
tile.wind_speed = entry[1]
|
|
}
|
|
|
|
// Pass 2: landmass friction
|
|
const frictionLand = params['wind_friction_land'] ?? 0.7
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id !== 'ocean' && tile.terrain_id !== 'coast') {
|
|
tile.wind_speed *= frictionLand
|
|
}
|
|
}
|
|
|
|
// Pass 3: mountain blocking
|
|
const mtCap = params['wind_friction_mountain_downwind'] ?? 0.1
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id !== 'mountains') continue
|
|
const mtDir = tile.wind_direction
|
|
const [dwQ, dwR] = AXIAL_DIRECTIONS[mtDir]
|
|
for (let step = 1; step <= 2; step++) {
|
|
const target = gm.getTile({
|
|
x: tile.axial.x + dwQ * step,
|
|
y: tile.axial.y + dwR * step,
|
|
})
|
|
if (target) target.wind_speed = Math.min(target.wind_speed, mtCap)
|
|
}
|
|
const faceA = (mtDir + 1) % 6
|
|
const faceB = (mtDir + 5) % 6
|
|
for (const faceDir of [faceA, faceB]) {
|
|
const [fq, fr] = AXIAL_DIRECTIONS[faceDir]
|
|
const faceTile = gm.getTile({ x: tile.axial.x + fq, y: tile.axial.y + fr })
|
|
if (faceTile && faceTile.terrain_id !== 'mountains') {
|
|
faceTile.wind_direction = faceDir
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const DIR_VARIABLE = -1
|
|
const CORIOLIS_NORTH = 1
|
|
const CORIOLIS_SOUTH = -1
|
|
|
|
function bandWindForRow(
|
|
row: number, h: number, centerRow: number, params: Record<string, number>,
|
|
): [number, number] {
|
|
const poleDist = Math.min(row, (h - 1) - row)
|
|
const poleDistFrac = poleDist / centerRow
|
|
const isNorth = row < centerRow
|
|
|
|
const polarCut = params['wind_band_polar_cut'] ?? 0.15
|
|
const polarFrontCut = params['wind_band_polar_front_cut'] ?? 0.30
|
|
const ferrelCut = params['wind_band_ferrel_cut'] ?? 0.50
|
|
const subtropicalCut = params['wind_band_subtropical_cut'] ?? 0.60
|
|
const hadleyCut = params['wind_band_hadley_cut'] ?? 0.85
|
|
|
|
let baseDir: number, baseSpeed: number
|
|
if (poleDistFrac < polarCut) {
|
|
baseDir = 0; baseSpeed = params['wind_speed_polar'] ?? 0.6
|
|
} else if (poleDistFrac < polarFrontCut) {
|
|
baseDir = DIR_VARIABLE; baseSpeed = params['wind_speed_polar_front'] ?? 0.3
|
|
} else if (poleDistFrac < ferrelCut) {
|
|
baseDir = 3; baseSpeed = params['wind_speed_ferrel'] ?? 0.8
|
|
} else if (poleDistFrac < subtropicalCut) {
|
|
baseDir = DIR_VARIABLE; baseSpeed = params['wind_speed_subtropical'] ?? 0.3
|
|
} else if (poleDistFrac < hadleyCut) {
|
|
baseDir = 0; baseSpeed = params['wind_speed_hadley'] ?? 0.7
|
|
} else {
|
|
baseDir = DIR_VARIABLE; baseSpeed = params['wind_speed_itcz'] ?? 0.3
|
|
}
|
|
|
|
let finalDir: number
|
|
if (baseDir === DIR_VARIABLE) {
|
|
finalDir = 0
|
|
} else {
|
|
const coriolis = isNorth ? CORIOLIS_NORTH : CORIOLIS_SOUTH
|
|
finalDir = ((baseDir + coriolis) % 6 + 6) % 6
|
|
}
|
|
return [finalDir, baseSpeed]
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Quality assignment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _quality() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Quality assignment (terrain_refiner.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function assignQuality(gm: GenMap): void {
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.terrain_id === 'ocean' || tile.terrain_id === 'coast' || tile.terrain_id === 'land') continue
|
|
let same = 0
|
|
for (const nb of axialNeighbors(tile.axial)) {
|
|
const nbTile = gm.getTile(nb)
|
|
if (nbTile && nbTile.terrain_id === tile.terrain_id) same++
|
|
}
|
|
if (same >= 3) tile.quality = 4
|
|
else if (same >= 1) tile.quality = 2
|
|
else tile.quality = 3
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hydrology
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _hydrology() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Hydrology: drainage, rivers, lakes, deltas (hydrology.gd + hydrology_rivers.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function generateDrainage(
|
|
gm: GenMap, elevation: Map<string, number>, moisture: Map<string, number>,
|
|
temperature: Map<string, number>, params: Record<string, unknown>, _rng: PCG32,
|
|
): void {
|
|
const rainfall = computeRainfall(gm, moisture, temperature, elevation, params)
|
|
const fill = depressionFill(gm, elevation, params)
|
|
const acc = accumulateFlow(gm, rainfall, fill.flowDir, fill.topoOrder)
|
|
detectLakes(gm, elevation, fill.filledElev, params)
|
|
markRivers(gm, acc, temperature, fill.flowDir, fill.topoOrder, params)
|
|
markDeltas(gm, acc, fill.flowDir, params)
|
|
classifySources(gm, elevation, moisture, temperature, fill.flowDir, params)
|
|
for (const [key, val] of acc) {
|
|
const tile = gm.tiles.get(key)
|
|
if (tile) tile.flow_accumulation = val
|
|
}
|
|
}
|
|
|
|
function computeRainfall(
|
|
gm: GenMap, moisture: Map<string, number>, temperature: Map<string, number>,
|
|
elevation: Map<string, number>, params: Record<string, unknown>,
|
|
): Map<string, number> {
|
|
const out = new Map<string, number>()
|
|
for (const tile of gm.tiles.values()) {
|
|
const key = axialKey(tile.axial)
|
|
if (isWaterTerrainHydro(tile.terrain_id)) { out.set(key, 0.0); continue }
|
|
const m = moisture.get(key) ?? tile.moisture
|
|
const t = temperature.get(key) ?? tile.temperature
|
|
let base = m * climateMult(t, params)
|
|
base = applyRainfallBonuses(base, tile, key, elevation, temperature, gm, params)
|
|
out.set(key, Math.max(0.0, base))
|
|
}
|
|
const globalMult = (params as Record<string, number>)['rainfall_global_multiplier'] ?? 1.0
|
|
if (globalMult !== 1.0) {
|
|
for (const [k, v] of out) out.set(k, v * globalMult)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function climateMult(temp: number, params: Record<string, unknown>): number {
|
|
const rf = (params['rainfall'] ?? {}) as Record<string, number>
|
|
if (temp > 0.65) return rf['multiplier_tropical'] ?? 1.4
|
|
if (temp >= 0.25) return rf['multiplier_temperate'] ?? 1.0
|
|
if (temp >= 0.10) return rf['multiplier_cold'] ?? 0.5
|
|
return rf['multiplier_frozen'] ?? 0.2
|
|
}
|
|
|
|
function applyRainfallBonuses(
|
|
base: number, tile: GenTile, key: string,
|
|
elevation: Map<string, number>, temperature: Map<string, number>,
|
|
gm: GenMap, params: Record<string, unknown>,
|
|
): number {
|
|
const sb = (params['source_bonuses'] ?? {}) as Record<string, Record<string, unknown>>
|
|
const terrain = tile.terrain_id
|
|
const elev = elevation.get(key) ?? tile.elevation
|
|
const temp = temperature.get(key) ?? tile.temperature
|
|
|
|
if (terrain === 'desert') {
|
|
const rf = (params['rainfall'] ?? {}) as Record<string, number>
|
|
base *= rf['multiplier_desert_terrain'] ?? 0.15
|
|
}
|
|
|
|
const snow = sb['snowmelt'] ?? {}
|
|
const snowTerrains = (snow['terrain'] ?? []) as string[]
|
|
if (snowTerrains.includes(terrain)
|
|
&& elev >= ((snow['min_elevation'] as number) ?? 0.65)
|
|
&& temp < ((snow['max_temperature'] as number) ?? 0.5)) {
|
|
base += (snow['bonus'] as number) ?? 0.8
|
|
}
|
|
|
|
const spr = sb['spring'] ?? {}
|
|
const sprTerrains = (spr['terrain'] ?? []) as string[]
|
|
if (sprTerrains.includes(terrain) && tile.moisture >= ((spr['min_moisture'] as number) ?? 0.6)) {
|
|
base += (spr['bonus'] as number) ?? 0.3
|
|
}
|
|
|
|
const hs = sb['hot_spring'] ?? {}
|
|
const hsTerrains = (hs['terrain'] ?? []) as string[]
|
|
if (hsTerrains.includes(terrain)
|
|
&& temp < ((hs['max_temperature'] as number) ?? 0.25)
|
|
&& adjTerrain(tile.axial, (hs['adjacent_terrain'] ?? []) as string[], gm)) {
|
|
base += (hs['bonus'] as number) ?? 0.5
|
|
}
|
|
|
|
const gl = sb['glacial'] ?? {}
|
|
const glTerrains = (gl['terrain'] ?? []) as string[]
|
|
if (glTerrains.includes(terrain) && elev >= ((gl['min_elevation'] as number) ?? 0.5)) {
|
|
base += (gl['bonus'] as number) ?? 0.4
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
function adjTerrain(axial: Vec2i, terrains: string[], gm: GenMap): boolean {
|
|
for (const [dq, dr] of AXIAL_DIRECTIONS) {
|
|
const nb = gm.getTile({ x: axial.x + dq, y: axial.y + dr })
|
|
if (nb && terrains.includes(nb.terrain_id)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
interface FillResult {
|
|
flowDir: Map<string, number>
|
|
filledElev: Map<string, number>
|
|
topoOrder: Vec2i[]
|
|
}
|
|
|
|
function depressionFill(
|
|
gm: GenMap, elevation: Map<string, number>, params: Record<string, unknown>,
|
|
): FillResult {
|
|
const eps = ((params['depression_fill'] ?? {}) as Record<string, number>)['epsilon'] ?? 0.0001
|
|
const filled = new Map<string, number>()
|
|
const flowDir = new Map<string, number>()
|
|
const done = new Set<string>()
|
|
const topoOrder: Vec2i[] = []
|
|
const heap: [number, number, number][] = []
|
|
|
|
for (const tile of gm.tiles.values()) {
|
|
const key = axialKey(tile.axial)
|
|
if (isWaterTerrainHydro(tile.terrain_id)) {
|
|
filled.set(key, 0.0)
|
|
flowDir.set(key, -1)
|
|
heapPush(heap, [0.0, tile.axial.x, tile.axial.y])
|
|
} else {
|
|
filled.set(key, 1e9)
|
|
flowDir.set(key, -1)
|
|
}
|
|
}
|
|
|
|
while (heap.length > 0) {
|
|
const e = heapPop(heap)!
|
|
const axial: Vec2i = { x: e[1], y: e[2] }
|
|
const key = axialKey(axial)
|
|
if (done.has(key)) continue
|
|
done.add(key)
|
|
topoOrder.push(axial)
|
|
for (let di = 0; di < 6; di++) {
|
|
const nb: Vec2i = {
|
|
x: axial.x + AXIAL_DIRECTIONS[di][0],
|
|
y: axial.y + AXIAL_DIRECTIONS[di][1],
|
|
}
|
|
const nbKey = axialKey(nb)
|
|
if (!gm.hasTile(nb) || done.has(nbKey)) continue
|
|
const nbTile = gm.getTile(nb)!
|
|
const nbReal = elevation.get(nbKey) ?? nbTile.elevation
|
|
const cand = Math.max(nbReal, e[0] + eps)
|
|
if (cand < (filled.get(nbKey) ?? 1e9)) {
|
|
filled.set(nbKey, cand)
|
|
flowDir.set(nbKey, OPPOSITE_DIR[di])
|
|
heapPush(heap, [cand, nb.x, nb.y])
|
|
}
|
|
}
|
|
}
|
|
|
|
return { flowDir, filledElev: filled, topoOrder }
|
|
}
|
|
|
|
function heapPush(h: [number, number, number][], e: [number, number, number]): void {
|
|
h.push(e)
|
|
let i = h.length - 1
|
|
while (i > 0) {
|
|
const p = (i - 1) >>> 1
|
|
if (h[p][0] <= h[i][0]) break
|
|
const tmp = h[p]; h[p] = h[i]; h[i] = tmp
|
|
i = p
|
|
}
|
|
}
|
|
|
|
function heapPop(h: [number, number, number][]): [number, number, number] | undefined {
|
|
if (h.length === 0) return undefined
|
|
const top = h[0]
|
|
const last = h.pop()!
|
|
if (h.length === 0) return top
|
|
h[0] = last
|
|
let i = 0
|
|
const n = h.length
|
|
while (true) {
|
|
const l = 2 * i + 1
|
|
const r = 2 * i + 2
|
|
let s = i
|
|
if (l < n && h[l][0] < h[s][0]) s = l
|
|
if (r < n && h[r][0] < h[s][0]) s = r
|
|
if (s === i) break
|
|
const tmp = h[i]; h[i] = h[s]; h[s] = tmp
|
|
i = s
|
|
}
|
|
return top
|
|
}
|
|
|
|
function accumulateFlow(
|
|
gm: GenMap, rainfall: Map<string, number>,
|
|
flowDir: Map<string, number>, topoOrder: Vec2i[],
|
|
): Map<string, number> {
|
|
const acc = new Map<string, number>()
|
|
for (const tile of gm.tiles.values()) {
|
|
const key = axialKey(tile.axial)
|
|
acc.set(key, rainfall.get(key) ?? 0.0)
|
|
}
|
|
for (let i = topoOrder.length - 1; i >= 0; i--) {
|
|
const axial = topoOrder[i]
|
|
const key = axialKey(axial)
|
|
const d = flowDir.get(key) ?? -1
|
|
if (d === -1) continue
|
|
const ds: Vec2i = {
|
|
x: axial.x + AXIAL_DIRECTIONS[d][0],
|
|
y: axial.y + AXIAL_DIRECTIONS[d][1],
|
|
}
|
|
const dsKey = axialKey(ds)
|
|
if (acc.has(dsKey)) {
|
|
// No terrain infiltration lookup in guide context — pass full flow through
|
|
acc.set(dsKey, acc.get(dsKey)! + (acc.get(key) ?? 0))
|
|
}
|
|
}
|
|
return acc
|
|
}
|
|
|
|
// -- Lake detection (hydrology_rivers.gd) --
|
|
|
|
function detectLakes(
|
|
gm: GenMap, elevation: Map<string, number>,
|
|
filledElev: Map<string, number>, params: Record<string, unknown>,
|
|
): void {
|
|
const cfg = (params['lakes'] ?? {}) as Record<string, number>
|
|
const depth = cfg['depth_threshold'] ?? 0.02
|
|
const seaMin = cfg['inland_sea_min_tiles'] ?? 12
|
|
const lakeTiles = new Set<string>()
|
|
for (const tile of gm.tiles.values()) {
|
|
if (isWaterTerrainHydro(tile.terrain_id)) continue
|
|
const key = axialKey(tile.axial)
|
|
const re = elevation.get(key) ?? tile.elevation
|
|
if ((filledElev.get(key) ?? re) - re > depth) lakeTiles.add(key)
|
|
}
|
|
const visited = new Set<string>()
|
|
let nextId = 0
|
|
for (const startKey of lakeTiles) {
|
|
if (visited.has(startKey)) continue
|
|
const group: string[] = []
|
|
const q = [startKey]
|
|
while (q.length > 0) {
|
|
const cur = q.shift()!
|
|
if (visited.has(cur)) continue
|
|
visited.add(cur)
|
|
group.push(cur)
|
|
const parts = cur.split(',')
|
|
const ax: Vec2i = { x: parseInt(parts[0]), y: parseInt(parts[1]) }
|
|
for (const [dq, dr] of AXIAL_DIRECTIONS) {
|
|
const nb: Vec2i = { x: ax.x + dq, y: ax.y + dr }
|
|
const nbKey = axialKey(nb)
|
|
if (lakeTiles.has(nbKey) && !visited.has(nbKey)) q.push(nbKey)
|
|
}
|
|
}
|
|
const tid = group.length < seaMin ? 'lake' : 'inland_sea'
|
|
for (const key of group) {
|
|
const tile = gm.tiles.get(key)
|
|
if (tile) { tile.terrain_id = tid; tile.lake_id = nextId }
|
|
}
|
|
nextId++
|
|
}
|
|
}
|
|
|
|
// -- River marking (hydrology_rivers.gd) --
|
|
|
|
function markRivers(
|
|
gm: GenMap, acc: Map<string, number>, temperature: Map<string, number>,
|
|
flowDir: Map<string, number>, topoOrder: Vec2i[], params: Record<string, unknown>,
|
|
): void {
|
|
const rcfg = (params['rivers'] ?? {}) as Record<string, number>
|
|
const density = (params as Record<string, number>)['hydrology_river_density_multiplier'] ?? 1.0
|
|
const frozenT = (params as Record<string, number>)['frozen_river_temperature'] ?? 0.10
|
|
for (const axial of topoOrder) {
|
|
const key = axialKey(axial)
|
|
const tile = gm.getTile(axial)
|
|
if (!tile || isWaterTerrainHydro(tile.terrain_id)) continue
|
|
const a = acc.get(key) ?? 0.0
|
|
const temp = temperature.get(key) ?? tile.temperature
|
|
if (a < riverThresh(temp, tile.terrain_id, rcfg) / density) continue
|
|
const d = flowDir.get(key) ?? -1
|
|
if (d === -1) continue
|
|
const dsPos: Vec2i = {
|
|
x: axial.x + AXIAL_DIRECTIONS[d][0],
|
|
y: axial.y + AXIAL_DIRECTIONS[d][1],
|
|
}
|
|
const ds = gm.getTile(dsPos)
|
|
if (!ds) continue
|
|
const fv = temp <= frozenT ? -a : a
|
|
if (!tile.river_edges.includes(d)) tile.river_edges.push(d)
|
|
tile.river_flow[String(d)] = fv
|
|
tile.river_flow['_flow_dir'] = d
|
|
const opp = OPPOSITE_DIR[d]
|
|
if (!isWaterTerrainHydro(ds.terrain_id)) {
|
|
if (!ds.river_edges.includes(opp)) ds.river_edges.push(opp)
|
|
ds.river_flow[String(opp)] = fv
|
|
}
|
|
}
|
|
}
|
|
|
|
function riverThresh(temp: number, terrainId: string, cfg: Record<string, number>): number {
|
|
if (terrainId === 'desert') return cfg['threshold_desert'] ?? 20.0
|
|
if (temp > 0.65) return cfg['threshold_tropical'] ?? 4.0
|
|
if (temp >= 0.25) return cfg['threshold_temperate'] ?? 6.0
|
|
if (temp >= 0.10) return cfg['threshold_cold'] ?? 10.0
|
|
return cfg['threshold_frozen'] ?? 15.0
|
|
}
|
|
|
|
// -- Delta formation (hydrology_rivers.gd) --
|
|
|
|
function markDeltas(
|
|
gm: GenMap, acc: Map<string, number>,
|
|
flowDir: Map<string, number>, params: Record<string, unknown>,
|
|
): void {
|
|
const cfg = (params['deltas'] ?? {}) as Record<string, number>
|
|
const thresh = cfg['accumulation_threshold'] ?? 15.0
|
|
const maxBr = cfg['max_branches'] ?? 3
|
|
const frozenT = (params as Record<string, number>)['frozen_river_temperature'] ?? 0.10
|
|
for (const tile of gm.tiles.values()) {
|
|
const key = axialKey(tile.axial)
|
|
if (isWaterTerrainHydro(tile.terrain_id) || (acc.get(key) ?? 0) < thresh) continue
|
|
const waterDirs: number[] = []
|
|
for (let di = 0; di < 6; di++) {
|
|
const nb = gm.getTile({
|
|
x: tile.axial.x + AXIAL_DIRECTIONS[di][0],
|
|
y: tile.axial.y + AXIAL_DIRECTIONS[di][1],
|
|
})
|
|
if (nb && isWaterTerrainHydro(nb.terrain_id)) waterDirs.push(di)
|
|
}
|
|
if (waterDirs.length === 0) continue
|
|
const br = Math.min(maxBr - 1, waterDirs.length)
|
|
let fv = acc.get(key) ?? 0.0
|
|
if (tile.temperature <= frozenT) fv = -fv
|
|
for (let i = 0; i < br; i++) {
|
|
const d = waterDirs[i]
|
|
if (!tile.river_edges.includes(d)) tile.river_edges.push(d)
|
|
tile.river_flow[String(d)] = fv
|
|
}
|
|
if (br < waterDirs.length) {
|
|
for (let di = 0; di < 6; di++) {
|
|
const nbPos: Vec2i = {
|
|
x: tile.axial.x + AXIAL_DIRECTIONS[di][0],
|
|
y: tile.axial.y + AXIAL_DIRECTIONS[di][1],
|
|
}
|
|
const nbKey = axialKey(nbPos)
|
|
if ((flowDir.get(nbKey) ?? -1) === OPPOSITE_DIR[di]) {
|
|
const up = gm.getTile(nbPos)
|
|
if (up && !isWaterTerrainHydro(up.terrain_id)) {
|
|
const d = waterDirs[br]
|
|
let upFv = acc.get(nbKey) ?? 0.0
|
|
if (up.temperature <= frozenT) upFv = -upFv
|
|
if (!up.river_edges.includes(d)) up.river_edges.push(d)
|
|
up.river_flow[String(d)] = upFv
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- Source classification (hydrology_rivers.gd) --
|
|
|
|
function classifySources(
|
|
gm: GenMap, elevation: Map<string, number>, moisture: Map<string, number>,
|
|
temperature: Map<string, number>, flowDir: Map<string, number>,
|
|
params: Record<string, unknown>,
|
|
): void {
|
|
const sb = (params['source_bonuses'] ?? {}) as Record<string, Record<string, unknown>>
|
|
for (const tile of gm.tiles.values()) {
|
|
if (tile.river_edges.length === 0) continue
|
|
const key = axialKey(tile.axial)
|
|
let hasUp = false
|
|
for (let di = 0; di < 6; di++) {
|
|
const nb: Vec2i = {
|
|
x: tile.axial.x + AXIAL_DIRECTIONS[di][0],
|
|
y: tile.axial.y + AXIAL_DIRECTIONS[di][1],
|
|
}
|
|
const nbKey = axialKey(nb)
|
|
if ((flowDir.get(nbKey) ?? -1) === OPPOSITE_DIR[di]) {
|
|
const nbt = gm.getTile(nb)
|
|
if (nbt && nbt.river_edges.length > 0) { hasUp = true; break }
|
|
}
|
|
}
|
|
if (hasUp) continue
|
|
|
|
const terrain = tile.terrain_id
|
|
const elev = elevation.get(key) ?? tile.elevation
|
|
const temp = temperature.get(key) ?? tile.temperature
|
|
const moist = moisture.get(key) ?? tile.moisture
|
|
|
|
const snow = sb['snowmelt'] ?? {}
|
|
const spr = sb['spring'] ?? {}
|
|
const hs = sb['hot_spring'] ?? {}
|
|
const gl = sb['glacial'] ?? {}
|
|
|
|
if (((snow['terrain'] ?? []) as string[]).includes(terrain)
|
|
&& elev >= ((snow['min_elevation'] as number) ?? 0.65)
|
|
&& temp < ((snow['max_temperature'] as number) ?? 0.5)) {
|
|
tile.river_source_type = 'snowmelt'
|
|
} else if (((spr['terrain'] ?? []) as string[]).includes(terrain)
|
|
&& moist >= ((spr['min_moisture'] as number) ?? 0.6)) {
|
|
tile.river_source_type = 'spring'
|
|
} else if (((hs['terrain'] ?? []) as string[]).includes(terrain)
|
|
&& temp < ((hs['max_temperature'] as number) ?? 0.25)
|
|
&& adjTerrain(tile.axial, (hs['adjacent_terrain'] ?? []) as string[], gm)) {
|
|
tile.river_source_type = 'hot_spring'
|
|
} else if (((gl['terrain'] ?? []) as string[]).includes(terrain)
|
|
&& elev >= ((gl['min_elevation'] as number) ?? 0.5)) {
|
|
tile.river_source_type = 'glacial'
|
|
} else {
|
|
tile.river_source_type = 'snowmelt'
|
|
}
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Render metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _render_metadata() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Render metadata storage (terrain_refiner.gd)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function storeRenderMetadata(
|
|
gm: GenMap, elevation: Map<string, number>,
|
|
moisture: Map<string, number>, temperature: Map<string, number>,
|
|
): void {
|
|
for (const tile of gm.tiles.values()) {
|
|
const key = axialKey(tile.axial)
|
|
tile.elevation = elevation.get(key) ?? 0.0
|
|
tile.moisture = moisture.get(key) ?? 0.0
|
|
tile.temperature = temperature.get(key) ?? 0.0
|
|
tile.variation_index = tile.quality - 1
|
|
}
|
|
}
|
|
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public generate() function
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _generate_function() -> str:
|
|
return """\
|
|
// ---------------------------------------------------------------------------
|
|
// Public API: generate()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function generate(
|
|
seed: number,
|
|
width: number,
|
|
height: number,
|
|
terrainCache: Map<string, TerrainData>,
|
|
params: Record<string, number>,
|
|
mapType: string,
|
|
): GridState {
|
|
const rng = new PCG32()
|
|
rng.seed(seed)
|
|
|
|
const w = width
|
|
const h = height
|
|
|
|
const typeData: Record<string, unknown> = {
|
|
...DEFAULT_TYPE_DATA,
|
|
id: mapType,
|
|
}
|
|
|
|
const oceanPct = (typeData['ocean_percentage'] as Record<string, number>) ?? { target: 0.40, variance: 0.05 }
|
|
const oceanTarget = (oceanPct['target'] ?? 0.40) + rng.randfRange(
|
|
-(oceanPct['variance'] ?? 0.05), oceanPct['variance'] ?? 0.05,
|
|
)
|
|
|
|
const genParams: Record<string, number> = {
|
|
...((typeData['generation_params'] ?? {}) as Record<string, number>),
|
|
...params,
|
|
}
|
|
|
|
// Create map and fill tile grid
|
|
const gm = new GenMap(w, h)
|
|
for (let col = 0; col < w; col++) {
|
|
for (let row = 0; row < h; row++) {
|
|
const ax = offsetToAxial(col, row)
|
|
gm.setTile(ax, newGenTile(ax, col, row))
|
|
}
|
|
}
|
|
|
|
// Per-tile float caches (axial-keyed)
|
|
const elevation = new Map<string, number>()
|
|
const moisture = new Map<string, number>()
|
|
const temperature = new Map<string, number>()
|
|
|
|
// Stage 1-2: Seed placement and region growth
|
|
const regions = placeRegionSeedsShaped(gm, genParams, mapType, rng)
|
|
growRegions(gm, regions, elevation, rng)
|
|
|
|
// Stage 3: Normalise elevation
|
|
normalizeElevation(gm, elevation)
|
|
|
|
// Stage 4: Sea level, ocean/land, coastline
|
|
assignSeaLevel(gm, oceanTarget, genParams, elevation)
|
|
|
|
// Stage 5: Tectonic relief
|
|
placeTectonicRelief(gm, elevation, genParams, rng)
|
|
|
|
// Stage 6: Temperature
|
|
computeTemperature(gm, elevation, temperature)
|
|
|
|
// Stage 7: Moisture
|
|
computeMoisture(gm, elevation, moisture, rng)
|
|
|
|
// Stage 8: Terrain patch expansion
|
|
assignTerrainPatches(gm, typeData, elevation, moisture, temperature, rng)
|
|
|
|
// Stage 9: Wind map + quality
|
|
computeWindMap(gm, genParams)
|
|
assignQuality(gm)
|
|
|
|
// Hydrology (rivers, lakes, deltas)
|
|
const hydroParams: Record<string, unknown> = { ...genParams }
|
|
generateDrainage(gm, elevation, moisture, temperature, hydroParams, rng)
|
|
|
|
// Render metadata
|
|
storeRenderMetadata(gm, elevation, moisture, temperature)
|
|
|
|
return gm.toGridState()
|
|
}
|
|
"""
|