Files
Battle-for-Tismo/scripts/classes/map_generator.gd
T
adrien c49899a2fd Initial commit
Server-authoritative Godot 4.6 strategy game.

Includes the headless WebSocket game server, the browser client, and
Nix packaging (server and web-export derivations plus a NixOS module)
for deployment.
2026-09-15 14:48:34 +02:00

263 lines
8.8 KiB
GDScript

class_name MapGenerator
extends RefCounted
## Builds a hex tile map from Perlin/simplex noise and chooses capital sites
## on the generated land. All tuning lives in a MapGenerationConfig resource.
var config : MapGenerationConfig
var topology : MapTopology
var land_cells : Array[Vector2i] = []
## Generated tiles keyed by coordinate. Each value is a plain dictionary with
## the atlas location, terrain classification and modifiers, so the simulation
## can work from data alone and the client can rebuild the TileMapLayer from it.
var tiles : Dictionary = {}
var _landmass_size : Dictionary = {}
func _init(map_config: MapGenerationConfig) -> void:
config = map_config
topology = config.make_topology()
## Fills the terrain layer with tiles chosen from its TileSet and records the
## land cells. Land tiles are picked by the cell's noise value (its elevation),
## the poles become ice, and everything below sea level is water.
func generate(terrain_layer: TileMapLayer, seed_value: int) -> void:
var noise := config.create_noise(seed_value)
var palette := TerrainPalette.new(terrain_layer.tile_set)
land_cells.clear()
tiles.clear()
terrain_layer.clear()
var half := config.map_size / 2
var wrapped := topology.is_cylindrical()
var radius := float(config.map_size.x) / TAU
for row in config.map_size.y:
for column in config.map_size.x:
var coords := Vector2i(column - half.x, row - half.y)
var value := _sample_noise(noise, column, coords, wrapped, radius)
value = _apply_falloff(coords, value, wrapped)
var tile := _choose_tile(coords, value, palette)
tiles[coords] = tile
terrain_layer.set_cell(coords, int(tile["source_id"]), tile["coords"])
if TerrainPalette.is_land(tile):
land_cells.append(coords)
_compute_landmasses(terrain_layer)
## The tile a cell should use: ice at the poles, otherwise a land band chosen
## by the noise value, otherwise the sea tile. Falls back to the configured
## atlas coordinates when the tileset carries no terrain layers yet.
func _choose_tile(coords: Vector2i, value: float, palette: TerrainPalette) -> Dictionary:
if palette.has_ice() and _is_polar(coords):
return palette.ice_tile()
if value >= config.sea_level:
if palette.has_land():
return palette.land_tile_for(value)
return _fallback_tile(config.land_atlas_coords, TerrainPalette.LAND)
if palette.has_sea():
return palette.sea_tile()
return _fallback_tile(config.sea_atlas_coords, TerrainPalette.SEA)
func _fallback_tile(atlas_coords: Vector2i, terrain_class: String) -> Dictionary:
return {
"source_id": config.tile_source_id,
"coords": atlas_coords,
"terrain_type": terrain_class,
"terrain_class": terrain_class,
"min_elevation": 0.0,
TerrainPalette.POPULATION_MULTIPLIER_LAYER: 0.0,
TerrainPalette.GDP_MULTIPLIER_LAYER: 0.0,
TerrainPalette.MOVEMENT_COST_MULTIPLIER_LAYER: 0.0,
}
## True when a cell sits in the frozen band at the top or bottom of the map.
func _is_polar(coords: Vector2i) -> bool:
var half := float(config.map_size.y) / 2.0
if half <= 0.0:
return false
return absf(float(coords.y)) / half >= config.ice_latitude
## Picks up to count land tiles, keeping the chosen sites apart by
## min_capital_distance when possible.
func pick_capitals(count: int, rng: RandomNumberGenerator) -> Array[Vector2i]:
if count <= 0 or land_cells.is_empty():
return []
var candidates := _capital_candidates(false)
if candidates.is_empty():
candidates = _capital_candidates(true)
if candidates.is_empty():
candidates.assign(land_cells)
_shuffle(candidates, rng)
var chosen: Array[Vector2i] = []
for coords in candidates:
if chosen.size() >= count:
break
if _is_far_enough(coords, chosen, config.min_capital_distance):
chosen.append(coords)
if chosen.size() < count:
for coords in candidates:
if chosen.size() >= count:
break
if not chosen.has(coords):
chosen.append(coords)
return chosen
## Places up to cities_per_civ cities for each starting civilisation, seeded by
## its capital. Sites are chosen on land and kept at least min_spacing tiles
## apart from every other city already placed. Cities are founded in distance
## order around each capital, one civilisation at a time, so no nation gets
## starved. Returns an array of dictionaries with "coords", "civ_index" and
## "is_capital" keys.
func pick_cities(
capitals: Array[Vector2i],
cities_per_civ: int,
min_spacing: int
) -> Array[Dictionary]:
var sites: Array[Dictionary] = []
if capitals.is_empty():
return sites
var occupied: Array[Vector2i] = []
for i in capitals.size():
sites.append({"coords": capitals[i], "civ_index": i, "is_capital": true})
occupied.append(capitals[i])
if cities_per_civ <= 1:
return sites
var candidate_lists: Array = []
var counts: Array[int] = []
counts.resize(capitals.size())
counts.fill(1)
for capital in capitals:
candidate_lists.append(_city_candidates(capital))
var placed_any := true
while placed_any:
placed_any = false
for i in capitals.size():
if counts[i] >= cities_per_civ:
continue
var candidates: Array = candidate_lists[i]
while not candidates.is_empty():
var coords: Vector2i = candidates.pop_front()
if not _is_far_enough(coords, occupied, min_spacing):
continue
sites.append({"coords": coords, "civ_index": i, "is_capital": false})
occupied.append(coords)
counts[i] += 1
placed_any = true
break
return sites
func _city_candidates(capital: Vector2i) -> Array:
var candidates: Array = []
for coords in land_cells:
if coords != capital:
candidates.append(coords)
candidates.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
var da := topology.tile_distance(capital, a)
var db := topology.tile_distance(capital, b)
if is_equal_approx(da, db):
if a.x == b.x:
return a.y < b.y
return a.x < b.x
return da < db)
return candidates
func _compute_landmasses(terrain_layer: TileMapLayer) -> void:
_landmass_size.clear()
var land_set := {}
for coords in land_cells:
land_set[coords] = true
var visited := {}
for coords in land_cells:
if visited.has(coords):
continue
var component: Array[Vector2i] = []
var frontier: Array[Vector2i] = [coords]
visited[coords] = true
while not frontier.is_empty():
var current: Vector2i = frontier.pop_back()
component.append(current)
for neighbour in topology.surrounding_cells(terrain_layer, current):
if land_set.has(neighbour) and not visited.has(neighbour):
visited[neighbour] = true
frontier.append(neighbour)
for tile in component:
_landmass_size[tile] = component.size()
## Samples the terrain noise. On a cylindrical map the horizontal axis is bent
## into a circle so the noise value at column 0 and column width match, which
## makes the wrapped seam invisible.
func _sample_noise(
noise: FastNoiseLite,
column: int,
coords: Vector2i,
wrapped: bool,
radius: float
) -> float:
if not wrapped:
return noise.get_noise_2d(float(coords.x), float(coords.y))
var angle := TAU * float(column) / float(config.map_size.x)
return noise.get_noise_3d(cos(angle) * radius, sin(angle) * radius, float(coords.y))
func _apply_falloff(coords: Vector2i, value: float, wrapped: bool = false) -> float:
if not config.falloff_enabled:
return value
var half := Vector2(config.map_size) * 0.5
if half.x <= 0.0 or half.y <= 0.0:
return value
# A cylinder has no east/west edge, so only the north/south poles fade out.
var normalized := absf(float(coords.y) / half.y) if wrapped \
else Vector2(float(coords.x) / half.x, float(coords.y) / half.y).length()
var fade := smoothstep(config.falloff_start, 1.0, normalized)
return value - fade * config.falloff_strength
func _capital_candidates(ignore_margin: bool) -> Array[Vector2i]:
var limit := config.map_size / 2 - Vector2i.ONE * config.capital_margin
var largest := 0
for coords in land_cells:
largest = maxi(largest, int(_landmass_size.get(coords, 0)))
if largest <= 0:
return []
var threshold := mini(maxi(config.min_capital_landmass, 1), largest)
var candidates: Array[Vector2i] = []
for coords in land_cells:
if int(_landmass_size.get(coords, 0)) < threshold:
continue
if not ignore_margin and _outside_margin(coords, limit):
continue
candidates.append(coords)
return candidates
## The east/west edge of a cylinder is not a real border, so capitals may sit
## right up against it; only the poles keep a margin.
func _outside_margin(coords: Vector2i, limit: Vector2i) -> bool:
if absi(coords.y) > limit.y:
return true
return not topology.is_cylindrical() and absi(coords.x) > limit.x
func _is_far_enough(coords: Vector2i, chosen: Array[Vector2i], min_distance: float) -> bool:
for other in chosen:
if topology.tile_distance(coords, other) < min_distance:
return false
return true
func _shuffle(values: Array[Vector2i], rng: RandomNumberGenerator) -> void:
for i in range(values.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var swap := values[i]
values[i] = values[j]
values[j] = swap