feat(generation): Add advanced procedural generation algorithms for hydrology, rivers, terrain, and balanced starting positions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-28 21:31:37 -07:00
parent f6495a989c
commit e1070b6467
7 changed files with 42 additions and 54 deletions

View file

@ -9,7 +9,6 @@ extends RefCounted
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const HydrologyRiversScript: GDScript = preload("res://engine/src/generation/hydrology_rivers.gd")
const OPPOSITE_DIR: Array[int] = [3, 4, 5, 0, 1, 2]
const WATER_TERRAINS: Array[String] = ["ocean", "coast", "lake", "inland_sea"]
# ---------------------------------------------------------------------------
@ -47,7 +46,7 @@ static func update_rivers(game_map: RefCounted, params: Dictionary) -> void:
var t: Variant = game_map.tiles[axial]
temperature[axial] = t.temperature
flow_dir[axial] = t.river_flow.get("_flow_dir", -1)
rainfall[axial] = 0.0 if _is_water(t) else \
rainfall[axial] = 0.0 if BiomeRegistry.has_tag(t.biome_id, "is_water") else \
maxf(0.0, t.moisture * _climate_mult(t.temperature, params))
var topo: Array = _kahn_sort(game_map, flow_dir)
var acc: Dictionary = _accumulate_flow(game_map, rainfall, flow_dir, topo)
@ -91,7 +90,7 @@ static func recompute_flow_directions(
var t: Variant = game_map.tiles[axial]
full_flow[axial] = t.river_flow.get("_flow_dir", -1)
temperature[axial] = t.temperature
rainfall[axial] = 0.0 if _is_water(t) else \
rainfall[axial] = 0.0 if BiomeRegistry.has_tag(t.biome_id, "is_water") else \
maxf(0.0, t.moisture * _climate_mult(t.temperature, mini_params))
var topo: Array = _kahn_sort(game_map, full_flow)
var acc: Dictionary = _accumulate_flow(game_map, rainfall, full_flow, topo)
@ -118,7 +117,7 @@ static func _compute_rainfall(
var out: Dictionary = {}
for axial: Vector2i in game_map.tiles:
var t: Variant = game_map.tiles[axial]
if _is_water(t):
if BiomeRegistry.has_tag(t.biome_id, "is_water"):
out[axial] = 0.0
continue
var base: float = moisture.get(axial, t.moisture) * _climate_mult(
@ -201,7 +200,7 @@ static func _depression_fill_subset(
var t: Variant = game_map.tiles.get(axial)
if t == null:
continue
if _is_water(t):
if BiomeRegistry.has_tag(t.biome_id, "is_water"):
filled[axial] = 0.0
flow_dir[axial] = -1
_hpush(heap, [0.0, axial.x, axial.y])
@ -326,9 +325,6 @@ static func _kahn_sort(game_map: RefCounted, flow_dir: Dictionary) -> Array:
return order
static func _is_water(tile: Variant) -> bool:
return tile.biome_id in WATER_TERRAINS
static func _adj_terrain(axial: Vector2i, terrains: Array, game_map: RefCounted) -> bool:
for dir: Vector2i in HexUtilsScript.AXIAL_DIRECTIONS:

View file

@ -5,7 +5,6 @@ extends RefCounted
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const OPPOSITE_DIR: Array[int] = [3, 4, 5, 0, 1, 2]
const WATER_TERRAINS: Array[String] = ["ocean", "coast", "lake", "inland_sea"]
# ---------------------------------------------------------------------------
@ -23,7 +22,7 @@ static func detect_lakes(
var lake_tiles: Dictionary = {}
for axial: Vector2i in game_map.tiles:
var t: Variant = game_map.tiles[axial]
if not _is_water(t):
if not BiomeRegistry.has_tag(t.biome_id, "is_water"):
var re: float = elevation.get(axial, t.elevation)
if filled_elev.get(axial, re) - re > depth:
lake_tiles[axial] = true
@ -66,7 +65,7 @@ static func mark_rivers(
var frozen_t: float = params.get("frozen_river_temperature", 0.10)
for axial: Vector2i in topo_order:
var t: Variant = game_map.tiles.get(axial)
if t == null or _is_water(t):
if t == null or BiomeRegistry.has_tag(t.biome_id, "is_water"):
continue
var a: float = acc.get(axial, 0.0)
var temp: float = temperature.get(axial, t.temperature)
@ -84,7 +83,7 @@ static func mark_rivers(
t.river_flow[d] = fv
t.river_flow["_flow_dir"] = d
var opp: int = OPPOSITE_DIR[d]
if not _is_water(ds):
if not BiomeRegistry.has_tag(ds.biome_id, "is_water"):
if opp not in ds.river_edges: ds.river_edges.append(opp)
ds.river_flow[opp] = fv
@ -92,7 +91,7 @@ static func mark_rivers(
static func _river_thresh(temp: float, biome_id: String, cfg: Dictionary) -> float:
# Desert terrain uses its own threshold regardless of temperature band --
# desert tiles are hot and would otherwise hit threshold_tropical, which is far too low.
if biome_id == "desert":
if BiomeRegistry.has_tag(biome_id, "is_dry"):
return cfg.get("threshold_desert", 20.0)
if temp > 0.65: return cfg.get("threshold_tropical", 4.0)
if temp >= 0.25: return cfg.get("threshold_temperate", 6.0)
@ -115,12 +114,12 @@ static func mark_deltas(
var frozen_t: float = params.get("frozen_river_temperature", 0.10)
for axial: Vector2i in game_map.tiles:
var t: Variant = game_map.tiles.get(axial)
if t == null or _is_water(t) or acc.get(axial, 0.0) < thresh:
if t == null or BiomeRegistry.has_tag(t.biome_id, "is_water") or acc.get(axial, 0.0) < thresh:
continue
var water_dirs: Array[int] = []
for di: int in 6:
var nb: Variant = game_map.tiles.get(axial + HexUtilsScript.AXIAL_DIRECTIONS[di])
if nb != null and _is_water(nb):
if nb != null and BiomeRegistry.has_tag(nb.biome_id, "is_water"):
water_dirs.append(di)
if water_dirs.is_empty():
continue
@ -137,7 +136,7 @@ static func mark_deltas(
var nb_pos: Vector2i = axial + HexUtilsScript.AXIAL_DIRECTIONS[di]
if flow_dir.get(nb_pos, -1) == OPPOSITE_DIR[di]:
var up: Variant = game_map.tiles.get(nb_pos)
if up != null and not _is_water(up):
if up != null and not BiomeRegistry.has_tag(up.biome_id, "is_water"):
var d: int = water_dirs[br]
var up_fv: float = acc.get(nb_pos, 0.0)
if up.temperature <= frozen_t:
@ -198,9 +197,6 @@ static func classify_sources(
# ---------------------------------------------------------------------------
static func _is_water(tile: Variant) -> bool:
return tile.biome_id in WATER_TERRAINS
static func _adj_terrain(axial: Vector2i, terrains: Array, game_map: RefCounted) -> bool:
for dir: Vector2i in HexUtilsScript.AXIAL_DIRECTIONS:

View file

@ -364,7 +364,7 @@ func _compute_moisture(game_map: RefCounted) -> void:
var queue: Array[Vector2i] = []
for axial: Vector2i in game_map.tiles:
var t: String = game_map.tiles[axial].biome_id
if t == "ocean" or t == "coast":
if BiomeRegistry.has_tag(t, "is_water"):
dist[axial] = 0
queue.append(axial)
@ -395,7 +395,7 @@ func _compute_moisture(game_map: RefCounted) -> void:
# Rain shadow: tiles in direction 0-1 rings downwind of each mountain
for axial: Vector2i in game_map.tiles:
if game_map.tiles[axial].biome_id != "mountains":
if not BiomeRegistry.has_tag(game_map.tiles[axial].biome_id, "is_elevated"):
continue
var wind: int = 0 # computed before quality, use base direction
var downwind: Vector2i = HexUtilsScript.AXIAL_DIRECTIONS[wind]
@ -420,7 +420,7 @@ func _derive_substrates(game_map: RefCounted) -> void:
var moist: float = _moisture.get(axial, 0.5)
# Water tiles: classify by depth
if tile.biome_id == "ocean" or tile.biome_id == "coast":
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
if elev < 0.15:
tile.substrate_id = "deep_water"
else:
@ -435,7 +435,7 @@ func _derive_substrates(game_map: RefCounted) -> void:
continue
# Volcanic (from terrain_refiner)
if tile.biome_id == "volcano":
if BiomeRegistry.has_tag(tile.biome_id, "is_volcanic"):
tile.substrate_id = "volcanic"
tile.soil_type = "volcanic_ash"
continue

View file

@ -91,7 +91,7 @@ func _get_wonder_candidates(game_map: RefCounted) -> Array[Vector2i]:
var margin: int = 3
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.biome_id == "ocean" or tile.biome_id == "mountains":
if BiomeRegistry.has_tag(tile.biome_id, "is_water") or BiomeRegistry.has_tag(tile.biome_id, "is_elevated"):
continue
var offset: Vector2i = HexUtilsScript.axial_to_offset(axial)
if (

View file

@ -54,7 +54,7 @@ static func score_start_zone(
continue
land_tiles += 1
biome_ids[tile.biome_id] = true
if tile.biome_id == "hills":
if BiomeRegistry.has_tag(tile.biome_id, "is_elevated"):
has_hills = true
var yields: Dictionary = tile.get_quality_yields()
var f: float = yields.get("food", 0)

View file

@ -278,7 +278,7 @@ func _score_all_start_candidates(
continue
if tile.is_natural_wonder():
continue
if tile.biome_id in ["mountains", "volcano", "ocean"]:
if BiomeRegistry.has_tag(tile.biome_id, "is_elevated") or BiomeRegistry.has_tag(tile.biome_id, "is_water"):
continue
var score: float = _score_start_position(game_map, axial, prefer_coast)
if score > 0:
@ -310,7 +310,7 @@ func _score_start_position(
food_total += yields.get("food", 0)
production_total += yields.get("production", 0)
terrain_variety[tile.biome_id] = true
if tile.biome_id == "hills":
if BiomeRegistry.has_tag(tile.biome_id, "is_elevated"):
has_hills = true
else:
has_coast = true
@ -329,13 +329,13 @@ func _score_start_position(
var center_tile: Variant = game_map.get_tile(axial)
if center_tile != null:
if center_tile.biome_id == "desert":
if BiomeRegistry.has_tag(center_tile.biome_id, "is_dry"):
score -= 10.0
elif center_tile.biome_id == "tundra":
elif BiomeRegistry.has_tag(center_tile.biome_id, "is_frozen"):
score -= 8.0
elif center_tile.biome_id == "swamp":
elif BiomeRegistry.has_tag(center_tile.biome_id, "is_wetland"):
score -= 5.0
elif center_tile.biome_id == "grassland":
elif BiomeRegistry.has_tag(center_tile.biome_id, "is_grassland"):
score += 3.0
var offset: Vector2i = HexUtilsScript.axial_to_offset(axial)

View file

@ -33,14 +33,14 @@ static func smooth_coastlines(
if neighbor == null:
continue
neighbor_count += 1
if not _is_water_terrain(neighbor.biome_id):
if not BiomeRegistry.has_tag(neighbor.biome_id, "is_water"):
land_count += 1
var water_count: int = neighbor_count - land_count
if _is_water_terrain(tile.biome_id) and land_count >= 5:
if BiomeRegistry.has_tag(tile.biome_id, "is_water") and land_count >= 5:
changes.append({"pos": axial, "terrain": "grassland"})
elif not _is_water_terrain(tile.biome_id) and water_count >= 5:
elif not BiomeRegistry.has_tag(tile.biome_id, "is_water") and water_count >= 5:
changes.append({"pos": axial, "terrain": "ocean"})
for change: Dictionary in changes:
@ -61,16 +61,16 @@ static func assign_coast_tiles(game_map: RefCounted) -> void:
var has_land_neighbor: bool = false
for dir: Vector2i in HexUtilsScript.AXIAL_DIRECTIONS:
var neighbor: Variant = game_map.tiles.get(axial + dir)
if neighbor != null and not _is_water_terrain(neighbor.biome_id):
if neighbor != null and not BiomeRegistry.has_tag(neighbor.biome_id, "is_water"):
has_land_neighbor = true
break
if has_land_neighbor:
tile.biome_id = "coast"
elif not _is_water_terrain(tile.biome_id):
elif not BiomeRegistry.has_tag(tile.biome_id, "is_water"):
for dir: Vector2i in HexUtilsScript.AXIAL_DIRECTIONS:
var neighbor: Variant = game_map.tiles.get(axial + dir)
if neighbor != null and _is_water_terrain(neighbor.biome_id):
if neighbor != null and BiomeRegistry.has_tag(neighbor.biome_id, "is_water"):
tile.is_coastal = true
break
@ -95,7 +95,7 @@ static func place_tectonic_relief(
var local_avg: Dictionary = {}
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.biome_id == "ocean" or tile.biome_id == "coast":
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
continue
var nearby: Array[Vector2i] = HexUtilsScript.hex_spiral(axial, 3)
var total: float = 0.0
@ -109,7 +109,7 @@ static func place_tectonic_relief(
var land_tiles: Array[Vector2i] = []
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.biome_id != "ocean" and tile.biome_id != "coast":
if not BiomeRegistry.has_tag(tile.biome_id, "is_water"):
land_tiles.append(axial)
var land_count: int = land_tiles.size()
@ -129,9 +129,7 @@ static func place_tectonic_relief(
var adj_ocean: bool = false
for nb: Vector2i in HexUtilsScript.get_neighbors(axial):
var nb_tile: Variant = game_map.tiles.get(nb)
if nb_tile != null and (
nb_tile.biome_id == "ocean" or nb_tile.biome_id == "coast"
):
if nb_tile != null and BiomeRegistry.has_tag(nb_tile.biome_id, "is_water"):
adj_ocean = true
break
@ -198,7 +196,7 @@ static func assign_terrain_patches(
for biome_id: String in order:
var target_count: int = 0
if biome_id == "tundra" or biome_id == "snow":
if BiomeRegistry.has_tag(biome_id, "is_frozen"):
var is_frozen: bool = biome_id == "snow"
for axial: Vector2i in game_map.tiles:
if game_map.tiles[axial].biome_id != "land":
@ -208,7 +206,7 @@ static func assign_terrain_patches(
target_count += 1
elif not is_frozen and t >= 0.10 and t < 0.25:
target_count += 1
elif biome_id == "jungle" or biome_id == "forest" or biome_id == "boreal_forest":
elif BiomeRegistry.has_tag(biome_id, "has_vegetation"):
# Forest types are placed by temperature zone, not fixed fraction count.
# Count eligible land tiles in the correct temperature band.
for axial: Vector2i in game_map.tiles:
@ -235,17 +233,17 @@ static func assign_terrain_patches(
var forest_family_count: int = 0
for axial: Vector2i in game_map.tiles:
var tid: String = game_map.tiles[axial].biome_id
if tid in ["forest", "jungle", "boreal_forest"]:
if BiomeRegistry.has_tag(tid, "has_vegetation"):
forest_family_count += 1
var base_count: int = forest_family_count if forest_family_count > 0 else land_count
target_count = roundi(
float(base_count) * fractions.get("enchanted_forest", 0.01)
)
elif biome_id == "grassland":
elif BiomeRegistry.has_tag(biome_id, "is_grassland"):
for axial: Vector2i in game_map.tiles:
if game_map.tiles[axial].biome_id == "land":
target_count += 1
elif biome_id == "volcano":
elif BiomeRegistry.has_tag(biome_id, "is_volcanic"):
var mt_count: int = game_map.get_tiles_by_terrain("mountains").size()
target_count = roundi(float(mt_count) * fractions.get("volcano", 0.02))
else:
@ -269,7 +267,7 @@ static func assign_quality(game_map: RefCounted) -> void:
## Set quality tier by same-terrain neighbour count (3+=mature, 1-2=nascent, 0=standard).
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.biome_id in ["ocean", "coast", "land"]:
if BiomeRegistry.has_tag(tile.biome_id, "is_water") or tile.biome_id == "land":
continue
var same: int = 0
for nb: Vector2i in HexUtilsScript.get_neighbors(axial):
@ -294,8 +292,9 @@ static func _expand_patch(
rng: RandomNumberGenerator,
) -> void:
var eligible: Array[Vector2i] = []
var is_volcanic: bool = BiomeRegistry.has_tag(biome_id, "is_volcanic")
for axial: Vector2i in game_map.tiles:
var src: String = "mountains" if biome_id == "volcano" else "land"
var src: String = "mountains" if is_volcanic else "land"
if game_map.tiles[axial].biome_id == src:
if _is_eligible(axial, biome_id, game_map, elevation, moisture, temperature):
eligible.append(axial)
@ -311,7 +310,7 @@ static func _expand_patch(
var idx: int = rng.randi_range(0, eligible.size() - 1)
var seed_pos: Vector2i = eligible[idx]
var tile: Variant = game_map.tiles.get(seed_pos)
var src_terrain: String = "mountains" if biome_id == "volcano" else "land"
var src_terrain: String = "mountains" if is_volcanic else "land"
if tile == null or tile.biome_id != src_terrain:
eligible.remove_at(idx)
continue
@ -350,7 +349,7 @@ static func _is_eligible(
"volcano":
for nb: Vector2i in HexUtilsScript.get_neighbors(axial):
var nb_tile: Variant = game_map.tiles.get(nb)
if nb_tile != null and nb_tile.biome_id == "mountains":
if nb_tile != null and BiomeRegistry.has_tag(nb_tile.biome_id, "is_elevated"):
return false
return true
"jungle":
@ -394,7 +393,4 @@ static func store_render_metadata(
# -- Private helpers --
static func _is_water_terrain(biome_id: String) -> bool:
return biome_id == "ocean" or biome_id == "coast"