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.
263 lines
8.8 KiB
GDScript
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
|