Files
Battle-for-Tismo/scripts/server/game_state.gd
T

1220 lines
40 KiB
GDScript

class_name GameState
extends RefCounted
## Authoritative game model. Everything that decides the outcome of the game
## lives here: the generated world, cities, units, territory, population, the
## economy and training queues. It is completely free of the scene tree (the
## only node it creates is a detached, data-only TileMapLayer used for hex
## neighbourhood maths), so the dedicated server can own it and tests can
## exercise it headlessly. Clients never run this class; they render snapshots
## of it and send orders back.
signal changed
const MAP_GENERATION: MapGenerationConfig = preload("res://data/map_generation.tres")
const ECONOMY: EconomyConfig = preload("res://data/economy.tres")
const TERRAIN_TILESET: TileSet = preload("res://tilesets/terrain_tileset.tres")
const DEFAULT_PROTO: ProtoUnit = preload("res://data/units/proto_modern_infantry.tres")
const TRAINABLE_UNITS: Array[ProtoUnit] = [DEFAULT_PROTO]
const MARKET: ProtoBuilding = preload("res://data/buildings/market.tres")
const BANK: ProtoBuilding = preload("res://data/buildings/bank.tres")
const RESEARCH_LAB: ProtoBuilding = preload("res://data/buildings/research_lab.tres")
const UNIVERSITY: ProtoBuilding = preload("res://data/buildings/university.tres")
const MUSEUM: ProtoBuilding = preload("res://data/buildings/museum.tres")
const CINEMA_STUDIO: ProtoBuilding = preload("res://data/buildings/cinema_studio.tres")
const BUILDINGS: Array[ProtoBuilding] = [
MARKET, BANK, RESEARCH_LAB, UNIVERSITY, MUSEUM, CINEMA_STUDIO,
]
const REPUBLIC: Government = preload("res://data/governments/republic.tres")
const MONARCHY: Government = preload("res://data/governments/monarchy.tres")
const TECHNOCRACY: Government = preload("res://data/governments/technocracy.tres")
const GOVERNMENTS: Array[Government] = [REPUBLIC, MONARCHY, TECHNOCRACY]
const SCIENTIFIC_METHOD: Technology = preload("res://data/technologies/scientific_method.tres")
const INDUSTRIAL_AUTOMATION: Technology = preload("res://data/technologies/industrial_automation.tres")
const RENEWABLE_ENERGY: Technology = preload("res://data/technologies/renewable_energy.tres")
const QUANTUM_COMPUTING: Technology = preload("res://data/technologies/quantum_computing.tres")
const SPACE_PROGRAM: Technology = preload("res://data/technologies/space_program.tres")
const TECHNOLOGIES: Array[Technology] = [
SCIENTIFIC_METHOD,
INDUSTRIAL_AUTOMATION,
RENEWABLE_ENERGY,
QUANTUM_COMPUTING,
SPACE_PROGRAM,
]
const PARIS: CityDescription = preload("res://data/cities/paris.tres")
const LONDON: CityDescription = preload("res://data/cities/london.tres")
## Cities that capitals are given explicitly, keyed by the civilisation resource
## path. Any other civilisation gets a generated city description.
const NAMED_CAPITALS := {
"res://data/civilisations/civ_france.tres": PARIS,
"res://data/civilisations/civ_britain.tres": LONDON,
}
const UNIT_VISION: int = 2
const TERRITORY_VISION: int = 1
const HOURS_PER_DAY: int = 24
const DAYS_PER_YEAR: int = 365
const HOURS_PER_YEAR: int = HOURS_PER_DAY * DAYS_PER_YEAR
const TERRAIN_CLASS_LAYER := "terrain_class"
const LAND_TERRAIN := "Land"
## Speed bonus a unit enjoys on tiles its own civilisation controls, rewarding
## movement through friendly territory over foreign or unclaimed land.
const CONTROLLED_SPEED_MULTIPLIER: float = 1.5
const DEFAULT_UNIT_SPEED: float = 0.5
const MIN_STEP_COST: float = 0.0001
var map_config: MapGenerationConfig = MAP_GENERATION
var economy: EconomyConfig = ECONOMY
var civilisations: Array[CivDescription] = []
var proto_units: Array[ProtoUnit] = []
var proto_buildings: Array[ProtoBuilding] = []
var governments: Array[Government] = []
var technologies: Array[Technology] = []
var seed: int = 0
var topology: MapTopology
var tiles: Dictionary = {}
var terrain_stats: TerrainStats
var land_cells: Array[Vector2i] = []
var cities: Array[Dictionary] = []
var units: Array[Dictionary] = []
var territory: Dictionary = {}
var tile_population: Dictionary = {}
var budgets: Dictionary = {}
var training: Dictionary = {}
## Accumulated science and culture points, keyed by civ index. Produced hourly
## by research and culture buildings; the technology and propaganda systems
## spend them.
var research: Dictionary = {}
var culture: Dictionary = {}
## Civ index -> index into `governments`. Every civilisation starts on the first
## catalogue entry; request_set_government changes it at a budget cost.
var government: Dictionary = {}
## Per-civilisation knowledge: civ index -> Dictionary of technology catalogue
## index -> true, for technologies already researched.
var researched: Dictionary = {}
## Per-civilisation knowledge: civ index -> Dictionary of coords -> true.
var explored: Dictionary = {}
var visible: Dictionary = {}
var total_hours: int = 0
var _configured: bool = false
var _terrain: TileMapLayer
var _generator: MapGenerator
var _pathfinder := HexPathfinder.new()
var _min_movement_cost: float = 1.0
var _max_step_length: float = 1.0
var _next_unit_id: int = 1
var _next_city_id: int = 1
var _city_names_used: Dictionary = {}
## Id -> unit/city dictionaries, so orders and training resolve in constant time
## instead of scanning the arrays (which grow as units are trained).
var _unit_by_id: Dictionary = {}
var _city_by_id: Dictionary = {}
## City lookup keyed by tile, and the tiles adjacent to a city keyed by the
## owning civilisation. Both are derived caches rebuilt when cities change;
## they keep per-tile economy queries off the O(cities) path.
var _city_by_coords: Dictionary = {}
var _city_adjacent: Dictionary = {}
## Territory grouped by owner, so per-civ economy and visibility queries only
## walk their own tiles instead of scanning every claimed tile. Rebuilt
## alongside `territory` in _recompute_territory.
var _territory_by_civ: Dictionary = {}
## A tile's GDP per capita depends only on terrain, city placement and
## ownership, all fixed once the world is generated, so it is memoised instead
## of being recomputed on every economy query.
var _gdp_per_capita_cache: Dictionary = {}
## Serialised territory and per-viewer explored sets. Both change rarely (only
## on configure/claiming, and when a unit reveals new ground), so the wire
## arrays are built once and reused instead of rebuilt for every snapshot.
var _serialized_territory: Array = []
var _serialized_explored: Dictionary = {}
## Terrain neighbourhoods, memoised because pathfinding asks for each tile's
## neighbours dozens of times. Cleared on configure; never mutated by callers.
var _neighbour_cache: Dictionary = {}
## Set when a unit moved or spawned since the last visibility refresh, so the
## whole map is not recomputed on idle hours.
var _visibility_dirty: bool = true
func _init() -> void:
_terrain = TileMapLayer.new()
_terrain.tile_set = TERRAIN_TILESET
proto_units.assign(TRAINABLE_UNITS)
proto_buildings.assign(BUILDINGS)
governments.assign(GOVERNMENTS)
technologies.assign(TECHNOLOGIES)
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and is_instance_valid(_terrain):
_terrain.free()
func is_configured() -> bool:
return _configured
## Builds the whole world for the given civilisations. Passing 0 as the seed
## picks a fresh random one. This is the only entry point that mutates the game
## setup; afterwards the state advances through advance_movement/tick_hour and
## the request_* methods.
func configure(civs: Array, world_seed: int = 0) -> void:
civilisations.assign(civs)
seed = world_seed if world_seed != 0 else randi()
_generator = MapGenerator.new(map_config)
_generator.generate(_terrain, seed)
tiles = _generator.tiles
land_cells = _generator.land_cells
topology = map_config.make_topology()
terrain_stats = TerrainStats.from_tiles(tiles)
_min_movement_cost = terrain_stats.min_movement_cost_multiplier()
_max_step_length = _compute_max_step_length()
cities.clear()
units.clear()
territory.clear()
tile_population.clear()
budgets.clear()
training.clear()
research.clear()
culture.clear()
government.clear()
researched.clear()
for i in civilisations.size():
research[i] = 0.0
culture[i] = 0.0
government[i] = 0
researched[i] = {}
explored.clear()
visible.clear()
_next_unit_id = 1
_next_city_id = 1
_unit_by_id.clear()
_city_by_id.clear()
_city_names_used.clear()
_city_by_coords.clear()
_city_adjacent.clear()
_territory_by_civ.clear()
_gdp_per_capita_cache.clear()
_serialized_territory = []
_serialized_explored.clear()
_neighbour_cache.clear()
total_hours = 0
_configured = true
_place_settlements()
_reveal_all()
_refresh_visibility()
_visibility_dirty = false
changed.emit()
## Starts training the given prototype in a city. Returns false when the city or
## the prototype is unknown or the city is already busy.
func request_train(city_id: int, proto_index: int) -> bool:
var city := find_city(city_id)
if city.is_empty() or training.has(city_id):
return false
if proto_index < 0 or proto_index >= proto_units.size():
return false
var proto := proto_units[proto_index]
var capacity := economy.production_capacity(get_player_gdp(int(city["civ"])))
if capacity <= 0.0 or proto.cost <= 0:
_spawn_unit(city["coords"], int(city["civ"]), proto)
_visibility_dirty = true
changed.emit()
return true
training[city_id] = {
"proto_index": proto_index,
"capacity": capacity,
"elapsed_hours": 0.0,
"total_hours": float(proto.cost) / capacity,
}
changed.emit()
return true
## Raises a city's building by one level. The cost is based on the owner's GDP
## and grows exponentially with the building's current level; it is paid from
## the budget. Returns false when the city or building is unknown, the building
## is at max_level, or the budget cannot cover the cost.
func request_build(city_id: int, proto_index: int) -> bool:
var city := find_city(city_id)
if city.is_empty() or proto_index < 0 or proto_index >= proto_buildings.size():
return false
var proto := proto_buildings[proto_index]
var civ := int(city["civ"])
var level := get_city_building_level(city, proto_index)
if level >= proto.max_level:
return false
var gdp := get_player_gdp(civ)
var cost := proto.build_cost(gdp, level)
if cost > 0.0 and get_budget(civ) < cost:
return false
if cost > 0.0:
budgets[civ] = get_budget(civ) - cost
var buildings: Dictionary = city["buildings"]
buildings[proto_index] = level + 1
changed.emit()
return true
## Lowers a city's building by one level, refunding a fraction of the removed
## level's cost. Demolishing the last level removes the building entirely.
## Returns false when the building is not built.
func request_demolish(city_id: int, proto_index: int) -> bool:
var city := find_city(city_id)
if city.is_empty() or proto_index < 0 or proto_index >= proto_buildings.size():
return false
var proto := proto_buildings[proto_index]
var civ := int(city["civ"])
var level := get_city_building_level(city, proto_index)
if level <= 0:
return false
var refund := proto.refund(get_player_gdp(civ), level)
if refund > 0.0:
budgets[civ] = get_budget(civ) + refund
var buildings: Dictionary = city["buildings"]
if level <= 1:
buildings.erase(proto_index)
else:
buildings[proto_index] = level - 1
changed.emit()
return true
## Switches a civilisation to another government from the catalogue. The cost is
## based on the owner's GDP and is paid from the budget; adopting the government
## already in power is rejected. Returns false when the civ or government index
## is unknown or the budget cannot cover the cost.
func request_set_government(civ: int, index: int) -> bool:
if civ < 0 or civ >= civilisations.size():
return false
if index < 0 or index >= governments.size():
return false
if get_government(civ) == index:
return false
var cost := governments[index].change_cost(get_player_gdp(civ))
if cost > 0.0 and get_budget(civ) < cost:
return false
if cost > 0.0:
budgets[civ] = get_budget(civ) - cost
government[civ] = index
changed.emit()
return true
## Researches a technology, spending its cost in accumulated research points.
## The technology must exist, not already be known, have every prerequisite
## researched and be affordable. Returns false otherwise.
func request_research_technology(civ: int, index: int) -> bool:
if not can_research_technology(civ, index):
return false
var technology := technologies[index]
if get_research(civ) < technology.cost:
return false
research[civ] = get_research(civ) - technology.cost
(researched[civ] as Dictionary)[index] = true
changed.emit()
return true
## Orders a unit to walk to a tile. Returns false when the order is invalid or
## no path exists. Movement then resolves continuously in advance_movement.
func request_move(unit_id: int, goal: Vector2i) -> bool:
var unit := find_unit(unit_id)
if unit.is_empty():
return false
var proto := unit_proto(unit)
if proto == null or not proto.moveable:
return false
var path := find_path(unit, unit["coords"], goal)
if path.size() < 2:
return false
unit["path"] = path
unit["path_index"] = 0
unit["progress_hours"] = 0.0
changed.emit()
return true
## Advances the simulation by one whole in-game hour. Tests and the offline
## fallback use this; the dedicated server instead calls advance_movement every
## frame and tick_hour when the clock strikes, so units glide continuously.
func advance_hour() -> void:
advance_movement(1.0)
tick_hour()
## Moves every unit along its path by the given number of in-game hours. The
## server calls this every frame with a fraction of an hour, so movement is
## smooth and begins the instant an order arrives instead of waiting for the
## next hour boundary.
func advance_movement(hours: float) -> void:
if hours <= 0.0:
return
Profiler.begin("state.advance_movement")
for unit in units:
var path: Array = unit["path"]
if path.is_empty():
continue
var hours_left := hours
while hours_left > 0.0 and int(unit["path_index"]) < path.size() - 1:
var next: Vector2i = path[int(unit["path_index"]) + 1]
var cost := maxf(_tile_travel_hours(unit, next), MIN_STEP_COST)
var needed := cost - float(unit["progress_hours"])
if hours_left >= needed:
hours_left -= needed
unit["path_index"] = int(unit["path_index"]) + 1
unit["progress_hours"] = 0.0
unit["coords"] = next
_visibility_dirty = true
else:
unit["progress_hours"] = float(unit["progress_hours"]) + hours_left
hours_left = 0.0
if int(unit["path_index"]) >= path.size() - 1:
unit["path"] = []
unit["path_index"] = 0
unit["progress_hours"] = 0.0
Profiler.end("state.advance_movement")
## Runs the systems that resolve once per in-game hour: movement is excluded
## (it runs continuously), while training, visibility and the daily
## population/economy ticks happen here. Emits changed once for a snapshot.
func tick_hour() -> void:
total_hours += 1
_tick_training()
if _visibility_dirty:
_refresh_visibility()
_visibility_dirty = false
if total_hours % HOURS_PER_DAY == 0:
_tick_population()
_tick_economy()
_tick_buildings()
changed.emit()
func find_unit(unit_id: int) -> Dictionary:
return _unit_by_id.get(unit_id, {})
func find_city(city_id: int) -> Dictionary:
return _city_by_id.get(city_id, {})
func unit_proto(unit: Dictionary) -> ProtoUnit:
var index := int(unit.get("proto", -1))
if index < 0 or index >= proto_units.size():
return null
return proto_units[index]
func city_at(coords: Vector2i) -> Dictionary:
return _city_by_coords.get(coords, {})
func unit_at(coords: Vector2i) -> Dictionary:
for unit in units:
if unit["coords"] == coords:
return unit
return {}
## Civ index owning the tile, or -1 when unclaimed.
func civ_at(coords: Vector2i) -> int:
return int(territory.get(coords, -1))
func terrain_type_at(coords: Vector2i) -> String:
var tile: Dictionary = tiles.get(coords, {})
return str(tile.get("terrain_type", "Unknown"))
func get_tile_population(coords: Vector2i) -> int:
return roundi(float(tile_population.get(coords, 0.0)))
func get_tile_info(coords: Vector2i) -> Dictionary:
var city := city_at(coords)
return {
"coords": coords,
"owner": civ_at(coords),
"terrain_type": terrain_type_at(coords),
"population": get_tile_population(coords),
"improvements": city.get("improvements", PackedStringArray()) if not city.is_empty() else PackedStringArray(),
}
func get_player_population(civ: int) -> int:
return int(_player_aggregates(civ)["population"])
## Population and GDP of a civilisation in a single pass over its tiles. The
## snapshot viewer stats need both, and two separate scans doubled the cost.
func _player_aggregates(civ: int) -> Dictionary:
var population := 0.0
var base_gdp := 0.0
for coords in _territory_by_civ.get(civ, []):
var pop := float(tile_population.get(coords, 0.0))
population += pop
base_gdp += pop * get_tile_gdp_per_capita(coords)
var modifiers := get_civ_modifiers(civ)
var gdp := base_gdp * (1.0 + float(modifiers.get(ProtoBuilding.EFFECT_GDP, 0.0)))
return {"population": roundi(population), "gdp": gdp}
func get_tile_gdp_per_capita(coords: Vector2i) -> float:
if _gdp_per_capita_cache.has(coords):
return _gdp_per_capita_cache[coords]
var owner := civ_at(coords)
if owner < 0:
return 0.0
var per_capita := float(economy.base_gdp_per_capita) * terrain_stats.gdp_multiplier(coords)
var city := city_at(coords)
if not city.is_empty():
per_capita *= economy.capital_tile_gdp_multiplier if city["is_capital"] else economy.city_tile_gdp_multiplier
elif _is_next_to_city(coords, owner):
per_capita *= 1.0 + economy.city_surrounding_gdp_bonus
_gdp_per_capita_cache[coords] = per_capita
return per_capita
func get_tile_gdp(coords: Vector2i) -> float:
return float(tile_population.get(coords, 0.0)) * get_tile_gdp_per_capita(coords)
func get_player_gdp(civ: int) -> float:
Profiler.begin("state.player_gdp")
var total := float(_player_aggregates(civ)["gdp"])
Profiler.end("state.player_gdp")
return total
## Total upkeep per game hour. Callers that already know the civilisation's GDP
## (the economy tick and viewer stats) pass it in to avoid a second full scan.
func get_player_upkeep(civ: int, gdp: float = -1.0) -> float:
var total := get_building_upkeep(civ, gdp)
for unit in units:
if int(unit["civ"]) != civ:
continue
var proto := unit_proto(unit)
if proto:
total += float(proto.upkeep)
return total
## Budget upkeep of every building a civilisation owns, per game hour. Upkeep
## is a fraction of GDP so it keeps pace with the economy as it grows.
func get_building_upkeep(civ: int, gdp: float = -1.0) -> float:
var total := 0.0
if gdp < 0.0:
gdp = get_player_gdp(civ)
for city in cities:
if int(city["civ"]) != civ:
continue
var buildings: Dictionary = city["buildings"]
for index in buildings.keys():
total += proto_buildings[int(index)].upkeep(gdp, int(buildings[index]))
return total
func get_city_building_level(city: Dictionary, proto_index: int) -> int:
return int((city.get("buildings", {}) as Dictionary).get(proto_index, 0))
## Summed effect of every building a civilisation owns, keyed by the effect
## stat. GDP and budget_income values are fractions (0.1 = +10%); research and
## culture values are per-hour point rates.
func get_civ_building_modifiers(civ: int) -> Dictionary:
var totals: Dictionary = {}
for city in cities:
if int(city["civ"]) != civ:
continue
var buildings: Dictionary = city["buildings"]
for index in buildings.keys():
var proto := proto_buildings[int(index)]
var level := int(buildings[index])
for effect in proto.effects:
totals[effect.stat] = float(totals.get(effect.stat, 0.0)) + effect.value_at(level)
return totals
## Summed effect of the government in power, keyed by effect stat. Governments
## have a single level, so every effect contributes its level-1 value.
func get_civ_government_modifiers(civ: int) -> Dictionary:
var totals: Dictionary = {}
var government_resource := get_government_resource(civ)
if government_resource == null:
return totals
for effect in government_resource.effects:
totals[effect.stat] = float(totals.get(effect.stat, 0.0)) + effect.value_at(1)
return totals
## All national modifiers a civilisation enjoys: its buildings, its government
## and its researched technologies. GDP and budget_income values are fractions
## (0.1 = +10%); research and culture values are per-hour point rates.
func get_civ_modifiers(civ: int) -> Dictionary:
var totals := get_civ_building_modifiers(civ)
_merge_modifiers(totals, get_civ_government_modifiers(civ))
_merge_modifiers(totals, get_civ_technology_modifiers(civ))
return totals
func _merge_modifiers(totals: Dictionary, additions: Dictionary) -> void:
for stat in additions.keys():
totals[stat] = float(totals.get(stat, 0.0)) + float(additions[stat])
func get_research(civ: int) -> float:
return float(research.get(civ, 0.0))
func get_culture(civ: int) -> float:
return float(culture.get(civ, 0.0))
## Index into `governments` of the government currently in power, or 0 when the
## civilisation has none set.
func get_government(civ: int) -> int:
return int(government.get(civ, 0))
## The government resource in power for a civilisation.
func get_government_resource(civ: int) -> Government:
var index := get_government(civ)
if index < 0 or index >= governments.size():
return null
return governments[index]
func is_researched(civ: int, index: int) -> bool:
return (researched.get(civ, {}) as Dictionary).has(index)
## Set of technology catalogue indices a civilisation has researched.
func get_researched_indices(civ: int) -> Dictionary:
return (researched.get(civ, {}) as Dictionary).duplicate()
## True when a technology exists, is not yet known and has all its prerequisites
## researched. Affordability is not considered.
func can_research_technology(civ: int, index: int) -> bool:
if civ < 0 or civ >= civilisations.size():
return false
if index < 0 or index >= technologies.size():
return false
if is_researched(civ, index):
return false
return technologies[index].prerequisites_met(technologies, researched.get(civ, {}))
## Summed effect of every technology a civilisation has researched, keyed by
## effect stat. Technologies have a single level, so effects contribute their
## level-1 value.
func get_civ_technology_modifiers(civ: int) -> Dictionary:
var totals: Dictionary = {}
for index in researched.get(civ, {}).keys():
for effect in technologies[int(index)].effects:
totals[effect.stat] = float(totals.get(effect.stat, 0.0)) + effect.value_at(1)
return totals
func get_budget(civ: int) -> float:
return float(budgets.get(civ, economy.starting_budget))
func get_training(city_id: int) -> Dictionary:
return training.get(city_id, {})
func is_explored(civ: int, coords: Vector2i) -> bool:
return explored.get(civ, {}).has(coords)
func is_visible(civ: int, coords: Vector2i) -> bool:
return visible.get(civ, {}).has(coords)
## True when the civilisation may enter the tile: it must be explored and the
## unit's prototype must list the tile's terrain class as traversable.
func can_enter(civ: int, proto: ProtoUnit, coords: Vector2i) -> bool:
if proto == null or not is_explored(civ, coords):
return false
var tile: Dictionary = tiles.get(coords, {})
if tile.is_empty():
return false
var terrain_class: Variant = tile.get(TERRAIN_CLASS_LAYER)
if terrain_class == null:
return false
return proto.traversable_terrains.has(str(terrain_class))
func find_path(unit: Dictionary, start: Vector2i, goal: Vector2i) -> Array[Vector2i]:
Profiler.begin("state.find_path")
var proto := unit_proto(unit)
var civ := int(unit["civ"])
# Terrain, ownership and knowledge do not change during one search, so the
# per-tile answers are cached instead of recomputed for every neighbour.
var enter_cache := {}
var cost_cache := {}
var heuristic_cache := {}
var result := _pathfinder.find_path(
start,
goal,
_neighbours,
func(coords: Vector2i) -> bool:
if not enter_cache.has(coords):
enter_cache[coords] = can_enter(civ, proto, coords)
return enter_cache[coords],
func(_from: Vector2i, to: Vector2i) -> float:
if not cost_cache.has(to):
cost_cache[to] = _tile_travel_hours(unit, to)
return cost_cache[to],
func(from: Vector2i, to: Vector2i) -> float:
if not heuristic_cache.has(from):
heuristic_cache[from] = _heuristic(unit, from, to)
return heuristic_cache[from]
)
Profiler.end("state.find_path")
return result
func _place_settlements() -> void:
var capitals := _place_capitals(civilisations.size())
for site in _place_cities(capitals):
_spawn_city(site)
_rebuild_city_index()
for i in mini(civilisations.size(), capitals.size()):
_spawn_unit(capitals[i], i, DEFAULT_PROTO)
_recompute_territory()
_generate_population()
_init_budgets()
func _place_capitals(count: int) -> Array[Vector2i]:
var rng := RandomNumberGenerator.new()
rng.seed = seed
return _generator.pick_capitals(count, rng)
func _place_cities(capitals: Array[Vector2i]) -> Array[Dictionary]:
return _generator.pick_cities(
capitals,
map_config.cities_per_civ,
map_config.city_spacing
)
func _spawn_city(site: Dictionary) -> void:
var civ_index: int = int(site["civ_index"])
if civ_index < 0 or civ_index >= civilisations.size():
return
var civ := civilisations[civ_index]
var coords: Vector2i = site["coords"]
var is_capital: bool = site["is_capital"]
var description := _city_description_for(civ, is_capital, coords)
var city := {
"id": _next_city_id,
"civ": civ_index,
"coords": coords,
"name": description.name,
"is_capital": is_capital,
"population": description.population,
"improvements": description.improvements.duplicate(),
"buildings": {},
}
cities.append(city)
_city_by_id[_next_city_id] = city
_next_city_id += 1
func _city_description_for(civ: CivDescription, is_capital: bool, center: Vector2i) -> CityDescription:
if is_capital and NAMED_CAPITALS.has(civ.resource_path):
return NAMED_CAPITALS[civ.resource_path]
return _generated_city_description(civ, center)
## Rebuilds the derived city caches used by city_at and _is_next_to_city. Must
## be called after any change to `cities`.
func _rebuild_city_index() -> void:
_city_by_coords.clear()
_city_adjacent.clear()
for city in cities:
var coords: Vector2i = city["coords"]
_city_by_coords[coords] = city
var owner := int(city["civ"])
if not _city_adjacent.has(owner):
_city_adjacent[owner] = {}
var adjacent: Dictionary = _city_adjacent[owner]
for neighbour in _neighbours(coords):
adjacent[neighbour] = true
func _generated_city_description(civ: CivDescription, center: Vector2i) -> CityDescription:
var index := civilisations.find(civ)
var used := int(_city_names_used.get(index, 0))
_city_names_used[index] = used + 1
var city_name := "%s %d" % [civ.name, used + 1]
if used < civ.city_names.size():
city_name = civ.city_names[used]
var description := CityDescription.new()
description.name = city_name
description.population = 500000 + absi(center.x * 92837111 ^ center.y * 689287499) % 3500000
return description
func _spawn_unit(center: Vector2i, civ_index: int, proto: ProtoUnit) -> void:
var index := proto_units.find(proto)
if index < 0:
proto_units.append(proto)
index = proto_units.size() - 1
var unit := {
"id": _next_unit_id,
"civ": civ_index,
"proto": index,
"coords": center,
"hp": proto.max_hp,
"max_hp": proto.max_hp,
"path": [],
"path_index": 0,
"progress_hours": 0.0,
}
units.append(unit)
_unit_by_id[_next_unit_id] = unit
_next_unit_id += 1
## Claims every land tile for the closest civilisation with a city on the same
## landmass, via a multi-source breadth-first search seeded from every city.
func _recompute_territory() -> void:
territory.clear()
_gdp_per_capita_cache.clear()
var owner_by_cell: Dictionary = {}
var frontier: Array[Vector2i] = []
for city in cities:
var coords: Vector2i = city["coords"]
var owner := int(city["civ"])
owner_by_cell[coords] = owner
territory[coords] = owner
frontier.append(coords)
while not frontier.is_empty():
var next: Array[Vector2i] = []
for coords in frontier:
var owner: int = owner_by_cell[coords]
for neighbour in _neighbours(coords):
if owner_by_cell.has(neighbour) or not _is_land(neighbour):
continue
owner_by_cell[neighbour] = owner
territory[neighbour] = owner
next.append(neighbour)
frontier = next
_territory_by_civ.clear()
for coords in territory.keys():
var owner := int(territory[coords])
if not _territory_by_civ.has(owner):
_territory_by_civ[owner] = []
_territory_by_civ[owner].append(coords)
_serialized_territory = _serialize_cells(territory)
func _generate_population() -> void:
tile_population.clear()
for coords in tiles.keys():
if _is_land(coords):
tile_population[coords] = _base_population(coords)
for city in cities:
tile_population[city["coords"]] = city["population"]
func _base_population(coords: Vector2i) -> int:
return roundi(float(economy.base_population) * terrain_stats.population_multiplier(coords))
func _init_budgets() -> void:
for i in civilisations.size():
budgets[i] = float(economy.starting_budget)
func _is_land(coords: Vector2i) -> bool:
var tile: Dictionary = tiles.get(coords, {})
return str(tile.get(TERRAIN_CLASS_LAYER, "")) == LAND_TERRAIN
func _is_next_to_city(coords: Vector2i, owner: int) -> bool:
return _city_adjacent.get(owner, {}).has(coords)
func _tick_population() -> void:
var factor := economy.population_growth_factor(
float(HOURS_PER_YEAR),
float(HOURS_PER_DAY)
)
if factor == 0.0:
return
for coords in tile_population.keys():
tile_population[coords] = float(tile_population[coords]) * (1.0 + factor)
func _tick_economy() -> void:
var hours := float(HOURS_PER_DAY)
for civ in budgets.keys():
var gdp := get_player_gdp(civ)
var modifiers := get_civ_modifiers(civ)
var income := economy.budget_income(gdp, hours) \
* (1.0 + float(modifiers.get(ProtoBuilding.EFFECT_BUDGET_INCOME, 0.0)))
var upkeep := get_player_upkeep(civ, gdp) * hours
budgets[civ] = float(budgets[civ]) + income - upkeep
## Accumulates science and culture every hour from the civ's research and
## culture buildings. Cheap when nothing produces them, which is the common case.
func _tick_buildings() -> void:
for civ in civilisations.size():
var modifiers := get_civ_modifiers(civ)
var science_rate := float(modifiers.get(ProtoBuilding.EFFECT_RESEARCH, 0.0))
var culture_rate := float(modifiers.get(ProtoBuilding.EFFECT_CULTURE, 0.0))
if science_rate == 0.0 and culture_rate == 0.0:
continue
research[civ] = get_research(civ) + science_rate
culture[civ] = get_culture(civ) + culture_rate
func _tick_training() -> void:
var completed: Array[int] = []
for city_id in training.keys():
var entry: Dictionary = training[city_id]
entry["elapsed_hours"] = float(entry["elapsed_hours"]) + 1.0
var produced := float(entry["elapsed_hours"]) * float(entry["capacity"])
var proto: ProtoUnit = proto_units[int(entry["proto_index"])]
if produced >= float(proto.cost):
var city := find_city(int(city_id))
if not city.is_empty():
_spawn_unit(city["coords"], int(city["civ"]), proto)
_visibility_dirty = true
completed.append(int(city_id))
for city_id in completed:
training.erase(city_id)
func _refresh_visibility() -> void:
Profiler.begin("state.refresh_visibility")
visible.clear()
for i in civilisations.size():
visible[i] = {}
for civ in _territory_by_civ.keys():
for coords in _territory_by_civ[civ]:
_reveal(int(civ), coords, TERRITORY_VISION)
for unit in units:
_reveal(int(unit["civ"]), unit["coords"], UNIT_VISION)
Profiler.end("state.refresh_visibility")
## The modern era has the whole world mapped, so every tile starts explored.
## Visibility (what is currently seen) is recomputed as units move.
func _reveal_all() -> void:
for i in civilisations.size():
explored[i] = {}
for coords in tiles.keys():
explored[i][coords] = true
func _reveal(civ: int, center: Vector2i, radius: int) -> void:
if not explored.has(civ):
explored[civ] = {}
if not visible.has(civ):
visible[civ] = {}
var explored_cells: Dictionary = explored[civ]
var visible_cells: Dictionary = visible[civ]
var discovered := false
for coords in _cells_in_radius(center, radius):
if not explored_cells.has(coords):
explored_cells[coords] = true
discovered = true
visible_cells[coords] = true
# Only new ground changes the serialised explored set; visibility is not
# cached because it changes every time a unit moves.
if discovered:
_serialized_explored.erase(civ)
## Serialised explored set for one viewer, built once and reused until a unit
## reveals a tile the civilisation had not seen before.
func _cached_explored(civ: int) -> Array:
if not _serialized_explored.has(civ):
_serialized_explored[civ] = _serialize_cells(explored.get(civ, {}))
return _serialized_explored[civ]
func _cells_in_radius(center: Vector2i, radius: int) -> Array[Vector2i]:
var result: Array[Vector2i] = [center]
var visited := {center: true}
var frontier: Array[Vector2i] = [center]
for _step in radius:
var next: Array[Vector2i] = []
for coords in frontier:
for neighbour in _neighbours(coords):
if visited.has(neighbour):
continue
visited[neighbour] = true
next.append(neighbour)
result.append(neighbour)
frontier = next
return result
func _neighbours(coords: Vector2i) -> Array[Vector2i]:
if _neighbour_cache.has(coords):
return _neighbour_cache[coords]
var result := topology.surrounding_cells(_terrain, coords)
_neighbour_cache[coords] = result
return result
func _is_controlled_by(unit: Dictionary, coords: Vector2i) -> bool:
return civ_at(coords) == int(unit["civ"])
func _unit_speed(unit: Dictionary) -> float:
var proto := unit_proto(unit)
var base := proto.speed if proto else DEFAULT_UNIT_SPEED
return maxf(base, 0.0001)
func _effective_speed_at(unit: Dictionary, coords: Vector2i) -> float:
var speed := _unit_speed(unit)
if _is_controlled_by(unit, coords):
speed *= CONTROLLED_SPEED_MULTIPLIER
return speed
func _tile_travel_hours(unit: Dictionary, coords: Vector2i) -> float:
return terrain_stats.movement_cost_multiplier(coords) / _effective_speed_at(unit, coords)
func _fastest_tile_travel_hours(unit: Dictionary) -> float:
return _min_movement_cost / (_unit_speed(unit) * CONTROLLED_SPEED_MULTIPLIER)
func _heuristic(unit: Dictionary, from: Vector2i, to: Vector2i) -> float:
var delta := topology.pixel_delta(_terrain, from, to)
var tiles := delta.length() / _max_step_length
return tiles * _fastest_tile_travel_hours(unit)
func _compute_max_step_length() -> float:
var longest := 0.0
for neighbour in _neighbours(Vector2i.ZERO):
longest = maxf(longest, topology.pixel_delta(_terrain, Vector2i.ZERO, neighbour).length())
return longest if longest > 0.0 else 1.0
## Serialises the whole state for the wire. coords become [x, y] pairs, civ
## references become indices and resource references become paths, so nothing
## that cannot travel over RPC is included. When a viewer civilisation is given,
## its explored/visible sets are included.
func snapshot(viewer_civ: int = -1) -> Dictionary:
return viewer_snapshot(serialize_shared(), viewer_civ)
## Everything in a snapshot that is identical for every viewer. The server
## builds this once per broadcast and layers the cheap per-viewer fields on top,
## instead of reserialising the whole world for every connected peer.
func serialize_shared() -> Dictionary:
Profiler.begin("state.snapshot")
var result := {
"seed": seed,
"total_hours": total_hours,
"civs": _serialize_civs(),
"protos": _serialize_protos(),
"buildings": _serialize_building_defs(),
"governments": _serialize_government_defs(),
"technologies": _serialize_technology_defs(),
"cities": _serialize_cities(),
"units": _serialize_units(),
"territory": _serialized_territory,
"population": _serialize_population(),
"budgets": _serialize_budgets(),
"research": _serialize_civ_floats(research),
"culture": _serialize_civ_floats(culture),
"government": _serialize_civ_ints(government),
"researched": _serialize_civ_researched(researched),
"training": training.duplicate(true),
}
Profiler.end("state.snapshot")
return result
## Adds the fields that differ per viewer to a shared snapshot. `stats` may be
## supplied by the caller to reuse a viewer_stats computed for another peer on
## the same civilisation.
func viewer_snapshot(shared: Dictionary, viewer_civ: int, stats: Dictionary = {}) -> Dictionary:
var result := shared.duplicate()
result["viewer"] = viewer_civ
result["viewer_stats"] = stats if not stats.is_empty() else viewer_stats(viewer_civ)
result["explored"] = _cached_explored(viewer_civ)
result["visible"] = _serialize_cells(visible.get(viewer_civ, {}))
return result
func viewer_stats(viewer_civ: int) -> Dictionary:
return _viewer_stats(viewer_civ)
func _viewer_stats(viewer_civ: int) -> Dictionary:
Profiler.begin("state.viewer_stats")
if viewer_civ < 0:
Profiler.end("state.viewer_stats")
return {
"population": 0, "gdp": 0.0, "gdp_per_capita": 0.0, "budget": 0.0,
"upkeep": 0.0, "research": 0.0, "culture": 0.0, "government": 0,
}
var aggregates := _player_aggregates(viewer_civ)
var population := int(aggregates["population"])
var gdp := float(aggregates["gdp"])
var result := {
"population": population,
"gdp": gdp,
"gdp_per_capita": gdp / population if population > 0 else 0.0,
"budget": get_budget(viewer_civ),
"upkeep": get_player_upkeep(viewer_civ, gdp),
"research": get_research(viewer_civ),
"culture": get_culture(viewer_civ),
"government": get_government(viewer_civ),
}
Profiler.end("state.viewer_stats")
return result
func _serialize_civs() -> Array:
var result := []
for civ in civilisations:
result.append({"path": civ.resource_path, "name": civ.name})
return result
func _serialize_protos() -> Array:
var result := []
for proto in proto_units:
result.append({"path": proto.resource_path, "name": proto.name})
return result
func _serialize_building_defs() -> Array:
var result := []
for proto in proto_buildings:
result.append({"path": proto.resource_path, "name": proto.name})
return result
func _serialize_government_defs() -> Array:
var result := []
for government_resource in governments:
result.append({"path": government_resource.resource_path, "name": government_resource.name})
return result
func _serialize_technology_defs() -> Array:
var result := []
for technology in technologies:
result.append({"path": technology.resource_path, "name": technology.name})
return result
func _serialize_cities() -> Array:
var result := []
for city in cities:
result.append({
"id": city["id"],
"civ": city["civ"],
"coords": [city["coords"].x, city["coords"].y],
"name": city["name"],
"is_capital": city["is_capital"],
"population": city["population"],
"improvements": Array(city["improvements"]),
"buildings": (city["buildings"] as Dictionary).duplicate(),
})
return result
func _serialize_units() -> Array:
var result := []
for unit in units:
var raw_path: Array = unit["path"]
var path_index := int(unit["path_index"])
var path := []
for coords in raw_path:
path.append([coords.x, coords.y])
var segment_hours := []
for i in range(1, raw_path.size()):
segment_hours.append(_tile_travel_hours(unit, raw_path[i]))
var step_hours := 0.0
if path_index + 1 < raw_path.size():
step_hours = _tile_travel_hours(unit, raw_path[path_index + 1])
result.append({
"id": unit["id"],
"civ": unit["civ"],
"proto": unit["proto"],
"coords": [unit["coords"].x, unit["coords"].y],
"hp": unit["hp"],
"max_hp": unit["max_hp"],
"path": path,
"path_index": unit["path_index"],
"progress_hours": unit["progress_hours"],
"step_hours": step_hours,
"segment_hours": segment_hours,
})
return result
func _serialize_cells(cells: Dictionary) -> Array:
var result := []
for coords in cells.keys():
result.append([coords.x, coords.y, cells[coords]])
return result
func _serialize_population() -> Array:
var result := []
for coords in tile_population.keys():
result.append([coords.x, coords.y, roundi(float(tile_population[coords]))])
return result
func _serialize_budgets() -> Array:
var result := []
for i in civilisations.size():
result.append(float(budgets.get(i, 0.0)))
return result
func _serialize_civ_floats(values: Dictionary) -> Array:
var result := []
for i in civilisations.size():
result.append(float(values.get(i, 0.0)))
return result
func _serialize_civ_ints(values: Dictionary) -> Array:
var result := []
for i in civilisations.size():
result.append(int(values.get(i, 0)))
return result
## Each civilisation's researched technologies as an array of catalogue indices.
func _serialize_civ_researched(values: Dictionary) -> Array:
var result := []
for i in civilisations.size():
var indices := []
for index in (values.get(i, {}) as Dictionary).keys():
indices.append(int(index))
indices.sort()
result.append(indices)
return result