magicciv/tools/transpile-engine/mapgen_assembly.py
Claude Code cc737f903d perf(transpile): Optimize transpilation pipeline and map generation assembly for better performance
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-26 00:06:47 -07:00

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()
}
"""