1220 lines
40 KiB
GDScript
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
|