spaceship/GameState.gd
2026-08-14 22:16:40 +02:00

1232 lines
42 KiB
GDScript

extends Node
const INVENTORY = preload("res://Inventory.gd")
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
# Autoload singleton - the one thing that survives change_scene_to_file()
# scene swaps between space (Main.tscn) and a planet's surface
# (PlanetSurface.tscn). Everything else in either scene gets torn down
# and rebuilt from scratch on each swap, so anything that needs to
# survive the trip has to live here instead.
var has_return_position: bool = false
var surviving_ship_count: int = 5
var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0
var planet_name: String = ""
var planet_id: String = ""
var planet_definition_id: String = "rocky"
# Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on
# every scene swap) so exploration progress isn't lost every time the
# player lands on and leaves a planet.
var explored_chunks: Dictionary = {}
# --- Resources -------------------------------------------------------------
const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID
const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID
const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER
# All resource-bearing locations use the same container. The old public
# names below are computed compatibility accessors, not separate storage.
var bank_inventory: RefCounted = INVENTORY.new({RESOURCE_ORE: 500})
var fleet_inventory: RefCounted = INVENTORY.new()
var rocks: int:
get: return bank_inventory.amount(RESOURCE_ORE)
set(value): bank_inventory.set_amount(RESOURCE_ORE, value)
var gas: int:
get: return bank_inventory.amount(RESOURCE_GAS)
set(value): bank_inventory.set_amount(RESOURCE_GAS, value)
var fleet_cargo: int:
get: return fleet_inventory.amount(RESOURCE_ORE)
set(value): fleet_inventory.set_amount(RESOURCE_ORE, value)
var fleet_gas_cargo: int:
get: return fleet_inventory.amount(RESOURCE_GAS)
set(value): fleet_inventory.set_amount(RESOURCE_GAS, value)
func collect_cargo(amount: int = 1) -> void:
fleet_inventory.deposit(RESOURCE_ORE, amount)
func collect_gas_cargo(amount: int = 1) -> void:
fleet_inventory.deposit(RESOURCE_GAS, amount)
func resource_types() -> PackedStringArray:
return RESOURCE_DEFINITIONS.ids()
func resource_definition(resource_type: String) -> Resource:
return RESOURCE_DEFINITIONS.get_definition(resource_type)
func resource_name(resource_type: String) -> String:
var definition: Resource = resource_definition(resource_type)
return String(definition.display_name) if definition != null else resource_type.capitalize()
func resource_color(resource_type: String) -> Color:
var definition: Resource = resource_definition(resource_type)
return definition.color if definition != null else Color.WHITE
func banked_resource(resource_type: String) -> int:
return bank_inventory.amount(resource_type)
func fleet_resource(resource_type: String) -> int:
return fleet_inventory.amount(resource_type)
func _inventory_for_record(record: Dictionary) -> RefCounted:
var inventory: RefCounted = record.get("inventory") as RefCounted
if inventory == null:
inventory = INVENTORY.new()
record.inventory = inventory
# Compatibility migration for state made before #33. Erase each legacy
# field once absorbed so it cannot drift out of step with the inventory.
if record.has("stock"):
inventory.set_amount(RESOURCE_ORE, int(record.stock))
record.erase("stock")
if record.has("gas_stock"):
inventory.set_amount(RESOURCE_GAS, int(record.gas_stock))
record.erase("gas_stock")
return inventory
func building_inventory(id: int) -> RefCounted:
var building: Dictionary = find_building(id)
if building.is_empty():
return null
if building_has_capability(building, "bank"):
return bank_inventory
return _inventory_for_record(building)
func building_resource(id: int, resource_type: String) -> int:
var inventory: RefCounted = building_inventory(id)
return 0 if inventory == null else inventory.amount(resource_type)
func transfer_resource_to_building(id: int, resource_type: String, amount: int) -> int:
return fleet_inventory.transfer_to(building_inventory(id), resource_type, amount)
func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int:
var source: RefCounted = building_inventory(id)
return 0 if source == null else source.transfer_to(fleet_inventory, resource_type, amount)
# The batch size the transfer menu offers, next to "All". A batch rather
# than only all-or-nothing: the hold is the part that can be lost, and
# emptying the bank in one tap would strand research, which spends from it.
const CARGO_LOAD_BATCH: int = 25
# --- moving ore by hand, in either direction (#24) --------------------------
#
# One pair of primitives the transfer menu drives, rather than the two
# one-way special cases that used to live inside the skill and craft panels
# ("load ore" at the mothership, "store ore here" at a crafter).
#
# The mothership is deliberately not a special case in the UI: it is simply
# the building whose holdings ARE the bank. Depositing at one banks the ore,
# withdrawing from one draws on the bank, and every other building keeps its
# own stock (#16). That is what lets one menu serve every structure.
func building_holdings(id: int) -> int:
return building_resource(id, RESOURCE_ORE)
func building_gas_holdings(id: int) -> int:
return building_resource(id, RESOURCE_GAS)
func transfer_to_building(id: int, amount: int) -> int:
# Hold -> this building. Returns how much actually moved, which is capped
# by the hold: a caller asking for "all" passes a number too big on
# purpose and reads the result back.
return transfer_resource_to_building(id, RESOURCE_ORE, amount)
func transfer_to_fleet(id: int, amount: int) -> int:
# This building -> hold. Capped by what the building actually holds, so
# there is no overdraft in either direction.
return transfer_resource_to_fleet(id, RESOURCE_ORE, amount)
func transfer_gas_to_building(id: int, amount: int) -> int:
return transfer_resource_to_building(id, RESOURCE_GAS, amount)
func transfer_gas_to_fleet(id: int, amount: int) -> int:
return transfer_resource_to_fleet(id, RESOURCE_GAS, amount)
const MOTHERSHIP_COST: int = 20
const SHIP_CRAFTER_COST: int = 12
const SHIP_COST: int = 6
# --- Buildings -------------------------------------------------------------
# Player-placed structures, as [{"type": String, "position": Vector2}].
# Buildings exist only in space (Main.tscn) but are stored here so they
# survive the scene swap to a planet's surface and back.
const BUILDING_MOTHERSHIP: String = "mothership"
const BUILDING_SHIP_CRAFTER: String = "ship_crafter"
const BUILDING_COMMAND_CENTER: String = "command_center"
# Extractors are not placed in space like the others - they are built ON a
# planet's surface and tracked separately (see `extractors`).
const BUILDING_EXTRACTOR: String = "extractor"
# Hosts the transport vessels that supply other buildings (#18). Built in
# space like the rest; it is the transporters (#19) that do the moving.
const BUILDING_TRANSPORT_HUB: String = "transport_hub"
# Where vessel types are researched and new ones unlocked (#5).
const BUILDING_SHIP_LAB: String = "ship_lab"
const COMMAND_CENTER_COST: int = 15
const EXTRACTOR_COST: int = 25
const TRANSPORT_HUB_COST: int = 30
const SHIP_LAB_COST: int = 35
var buildings: Array = []
# --- Skill tree ------------------------------------------------------------
# Researched at the mothership. Two things hang off it: which structures the
# player is allowed to build at all, and permanent upgrades that have no
# building of their own. The mothership itself is deliberately NOT in the
# tree - it is the thing you research AT, so gating it behind research would
# be a deadlock.
#
# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT
# stored, it is derived from how many prerequisites deep the node is, so the
# tree always reads root-at-the-bottom and cannot drift out of step with the
# dependencies when nodes are added. `requires` is a single prerequisite id
# ("" for a root).
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
"x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER,
"desc": "Build ship crafters, and buy extra vessels at them.",
},
{
"id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards",
"x": 0.18, "unlocks": BUILDING_EXTRACTOR,
"desc": "Build extractors on planets. They mine ore whether or not you are nearby.",
},
{
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
"x": 0.50, "unlocks": BUILDING_COMMAND_CENTER,
"desc": "Build command centers, where the fleet formation is arranged.",
},
{
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
"desc": "Build transport hubs, which supply your other buildings with ore.",
},
{
"id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards",
"x": 0.82, "unlocks": BUILDING_SHIP_LAB,
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
},
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"x": 0.18, "hub_bonus": 1,
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
},
{
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
"x": 0.50, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
"x": 0.50, "fleet_bonus": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
]
const BASE_FLEET_SIZE: int = 5
var unlocked: Array = []
func skill_node(id: String) -> Dictionary:
for node in SKILL_TREE:
if node.id == id:
return node
return {}
func skill_depth(id: String) -> int:
# How many prerequisites deep this node sits. Drives its height in the
# tree view: roots at the bottom, everything that depends on something
# above it.
var depth: int = 0
var node: Dictionary = skill_node(id)
while not node.is_empty() and node.requires != "":
depth += 1
node = skill_node(node.requires)
if depth > SKILL_TREE.size():
break # a cycle in the data would otherwise hang here
return depth
func max_skill_depth() -> int:
var deepest: int = 0
for node in SKILL_TREE:
deepest = maxi(deepest, skill_depth(node.id))
return deepest
func is_unlocked(id: String) -> bool:
return id in unlocked
func prerequisite_met(id: String) -> bool:
var node: Dictionary = skill_node(id)
if node.is_empty():
return false
return node.requires == "" or is_unlocked(node.requires)
func can_unlock(id: String) -> bool:
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost))
func unlock(id: String) -> bool:
if not can_unlock(id):
return false
if not spend(int(skill_node(id).cost)):
return false
unlocked.append(id)
return true
func is_building_unlocked(type: String) -> bool:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return is_unlocked(node.id)
return true
func research_for_building(type: String) -> String:
for node in SKILL_TREE:
if node.get("unlocks", "") == type:
return node.name
return ""
func max_fleet_size() -> int:
var n: int = BASE_FLEET_SIZE
for id in unlocked:
n += int(skill_node(id).get("fleet_bonus", 0))
return mini(n, DEFAULT_FORMATION.size())
func reset_progression() -> void:
unlocked.clear()
vessel_levels.clear()
# --- building stock and production queue (#16) -----------------------------
# Since #12, a building cannot just deduct from the bank at the moment of
# purchase: what it produces has to be paid for out of resources physically
# delivered to it. So each building carries its own `stock`, and an order it
# cannot afford yet is QUEUED rather than refused, and completes by itself
# once the stock turns up.
#
# `id` exists because a building has to be referable across the space <->
# planet-surface scene swap, when every Building NODE has been destroyed and
# rebuilt. Position would nearly work and then fail on two buildings placed
# on the same spot.
var next_building_id: int = 1
# Finished vessels waiting at the mothership for the player to collect
# (#23), as a list of VESSEL_* types - a shield vessel must not come back as
# an interceptor. They are NOT handed to the fleet automatically: the player
# picks what to crew from the hangar, so a full fleet no longer stops a
# crafter from building.
#
# Production is simulated here, in the autoload, the same way extractor
# haulers are, so a build finishes whether or not the player is in space.
var stored_vessels: Array = []
# Finished crafter orders travel to the mothership before appearing in its
# hangar. State is authoritative here so delivery continues off-screen.
# {"type", "from", "to", "progress"}
var vessel_deliveries: Array = []
const VESSEL_DELIVERY_SPEED: float = 950.0
signal order_completed(building_id: int, type: String)
func add_building(type: String, at_position: Vector2) -> int:
var id: int = next_building_id
next_building_id += 1
buildings.append({
"id": id, "type": type, "position": at_position,
"inventory": INVENTORY.new(), "orders": [],
})
return id
func find_building(id: int) -> Dictionary:
for b in buildings:
if b.get("id", 0) == id:
return b
return {}
func building_has_capability(building: Dictionary, capability: String) -> bool:
if building.is_empty():
return false
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
return definition != null and capability in definition.capabilities
# --- vessel types and their upgrades (#5) ---------------------------------
# The three things that can be flying around. The first and last double as
# production order ids, so a vessel type and the order that builds it are the
# same string rather than two that have to be kept in step.
const VESSEL_INTERCEPTOR: String = "interceptor"
const VESSEL_SHIELD: String = "shield"
const VESSEL_TRANSPORTER: String = "transporter"
# Compatibility constants for callers and old tests. Runtime metadata is
# resolved from VesselDefinition below.
const SHIELD_COST: int = 14
const TRANSPORTER_COST: int = 10
const LAB_RESEARCH_TIME: float = 8.0
# Test seam. Headless tests need the timed path - queued, started, finishing
# - without sitting through a real 10-second build each, and a suite that
# slow stops being run. Left at 1.0 in the game.
var build_time_scale: float = 1.0
func build_time(product: String) -> float:
var definition: Resource = VESSEL_DEFINITIONS.get_definition(product)
return (definition.build_time if definition != null else 10.0) * build_time_scale
func lab_research_time() -> float:
return LAB_RESEARCH_TIME * build_time_scale
# Compatibility constants. VesselDefinition owns the live base figures.
const SHIP_BASE_DAMAGE: int = 1
const SHIP_BASE_COOLDOWN: float = 0.5
const SHIP_BASE_RANGE: float = 320.0
const SHIP_BASE_SPEED: float = 460.0
const SHIP_BASE_ACCEL: float = 450.0
const SHIELD_BASE_CHARGES: int = 1
const SHIELD_BASE_RECHARGE: float = 6.0
const TRANSPORTER_BASE_LOAD: int = 20
const TRANSPORTER_BASE_SPEED: float = 340.0
var vessel_levels: Dictionary = {}
signal vessel_upgraded(upgrade_id: String)
func has_ship_lab() -> bool:
for b in buildings:
if building_has_capability(b, "vessel_research"):
return true
return false
func shield_vessel_unlocked() -> bool:
return vessel_is_unlocked(VESSEL_SHIELD)
const UNLOCK_SHIELD_VESSEL: String = "unlock_shield"
const UNLOCK_SHIELD_COST: int = 45
func unlock_shield_vessel() -> bool:
return unlock_vessel(VESSEL_SHIELD)
func vessel_definition(vessel: String) -> Resource:
return VESSEL_DEFINITIONS.get_definition(vessel)
func vessel_name(vessel: String) -> String:
var definition: Resource = vessel_definition(vessel)
return definition.display_name if definition != null else vessel.capitalize()
func vessel_has_capability(vessel: String, capability: String) -> bool:
var definition: Resource = vessel_definition(vessel)
return definition != null and definition.has_capability(capability)
func vessel_is_unlocked(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null:
return false
return definition.unlock_id == "" or int(vessel_levels.get(definition.unlock_id, 0)) > 0
func vessel_for_unlock(unlock_id: String) -> Resource:
for definition in VESSEL_DEFINITIONS.all():
if definition.unlock_id == unlock_id:
return definition
return null
func unlock_vessel(vessel: String) -> bool:
var definition: Resource = vessel_definition(vessel)
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
return false
if not spend(definition.unlock_cost):
return false
vessel_levels[definition.unlock_id] = 1
vessel_upgraded.emit(definition.unlock_id)
return true
func upgrade_def(id: String) -> Dictionary:
for definition in VESSEL_DEFINITIONS.all():
for upgrade in definition.upgrades:
if upgrade.upgrade_id == id:
return {"id": upgrade.upgrade_id, "vessel": definition.definition_id,
"name": upgrade.display_name, "desc": upgrade.description,
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
"icon": upgrade.icon, "resource": upgrade}
return {}
func upgrades_for(vessel: String) -> Array:
var out: Array = []
var definition: Resource = vessel_definition(vessel)
if definition == null:
return out
for upgrade in definition.upgrades:
out.append({"id": upgrade.upgrade_id, "vessel": vessel,
"name": upgrade.display_name, "desc": upgrade.description,
"cost": upgrade.base_cost, "max_level": upgrade.max_level,
"icon": upgrade.icon, "resource": upgrade})
return out
func upgrade_level(id: String) -> int:
return int(vessel_levels.get(id, 0))
func upgrade_maxed(id: String) -> bool:
var d: Dictionary = upgrade_def(id)
return d.is_empty() or upgrade_level(id) >= int(d.max_level)
func upgrade_cost(id: String) -> int:
var d: Dictionary = upgrade_def(id)
if d.is_empty():
return 0
return int(d.cost) * (upgrade_level(id) + 1)
func can_buy_upgrade(id: String) -> bool:
return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id))
func buy_upgrade(id: String) -> bool:
if not can_buy_upgrade(id):
return false
if not spend(upgrade_cost(id)):
return false
vessel_levels[id] = upgrade_level(id) + 1
vessel_upgraded.emit(id)
return true
func vessel_stat(vessel: String, stat: String) -> float:
var definition: Resource = vessel_definition(vessel)
if definition == null:
return 0.0
var value: float = float(definition.base_stats.get(stat, 0.0))
for upgrade in definition.upgrades:
var level: int = upgrade_level(upgrade.upgrade_id)
value += float(upgrade.additive_modifiers.get(stat, 0.0)) * level
if upgrade.multiplicative_modifiers.has(stat):
value *= pow(float(upgrade.multiplicative_modifiers[stat]), level)
if definition.minimum_stats.has(stat):
value = maxf(value, float(definition.minimum_stats[stat]))
return value
func ship_damage(vessel: String) -> int: return int(vessel_stat(vessel, "damage"))
func ship_fire_cooldown(vessel: String) -> float: return vessel_stat(vessel, "fire_cooldown")
func ship_fire_range(vessel: String) -> float: return vessel_stat(vessel, "fire_range")
func ship_max_speed(vessel: String) -> float: return vessel_stat(vessel, "max_speed")
func ship_acceleration(vessel: String) -> float: return vessel_stat(vessel, "acceleration")
func shield_max_charges() -> int:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
return int(vessel_stat(definition.definition_id, "shield_charges"))
func shield_recharge_time() -> float:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield")
return vessel_stat(definition.definition_id, "shield_recharge")
func transporter_load_limit() -> int:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return int(vessel_stat(definition.definition_id, "cargo_capacity"))
func transporter_speed() -> float:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return vessel_stat(definition.definition_id, "transport_speed")
const PRODUCT_SHIP: String = "interceptor"
const PRODUCT_TRANSPORTER: String = "transporter"
func product_cost(product: String) -> int:
var definition: Resource = vessel_definition(product)
return definition.build_cost if definition != null else 0
func can_produce(type: String) -> bool:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
return definition != null and "production" in definition.capabilities
func building_stock(id: int) -> int:
return building_resource(id, RESOURCE_ORE)
func pending_orders(id: int) -> int:
var b: Dictionary = find_building(id)
return 0 if b.is_empty() else (b.get("orders", []) as Array).size()
func pending_orders_of(id: int, product: String) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var n: int = 0
for o in b.get("orders", []):
if String(o.product) == product:
n += 1
return n
func order_list(id: int) -> Array:
# What the queue panel draws: product, and how far along the one being
# built is. Everything behind it reads as simply waiting.
var out: Array = []
var b: Dictionary = find_building(id)
if b.is_empty():
return out
for o in b.get("orders", []):
var product: String = String(o.product)
out.append({
"product": product,
"started": bool(o.started),
"progress": clampf(float(o.elapsed) / maxf(build_time(product), 0.001), 0.0, 1.0),
})
return out
func cancel_order(id: int, index: int) -> bool:
# Refunds whatever was actually taken: a build that has started has its
# ore in the building already, one still waiting never paid.
var b: Dictionary = find_building(id)
if b.is_empty():
return false
var orders: Array = b.get("orders", [])
if index < 0 or index >= orders.size():
return false
var order: Dictionary = orders[index]
if bool(order.started):
_inventory_for_record(b).deposit(RESOURCE_ORE, product_cost(String(order.product)))
orders.remove_at(index)
return true
func outstanding_cost(id: int) -> int:
# What this building still needs delivered to clear everything queued.
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var need: int = 0
for o in b.get("orders", []):
# A started build has already paid, so it is not still owed.
if not bool(o.started):
need += product_cost(String(o.product))
return maxi(need - _inventory_for_record(b).amount(RESOURCE_ORE), 0)
func deposit_to_building(id: int, amount: int) -> int:
if amount <= 0:
return 0
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
return _inventory_for_record(b).deposit(RESOURCE_ORE, amount)
func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or not can_produce(b.type):
return false
var definition: Resource = vessel_definition(product)
if definition == null or not vessel_is_unlocked(product):
return false
if definition.has_capability("transport") and not has_transport_hub():
# Nowhere to station it. Refused rather than queued, so the player is
# told now instead of watching an order sit forever.
return false
if definition.has_capability("transport") and not can_station_transporter():
# Every berth is taken or spoken for (#20).
return false
(b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false})
return true
func _advance_production(delta: float) -> void:
for b in buildings:
if not can_produce(b.type):
continue
var orders: Array = b.get("orders", [])
if orders.is_empty():
continue
# ONE at a time, in order. A queue the player can watch and cancel
# has to have a thing being built and things waiting behind it -
# clearing the whole queue the instant the ore lands (which is what
# this did before #23) leaves nothing to see or cancel.
var order: Dictionary = orders[0]
var product: String = String(order.product)
var cost: int = product_cost(product)
var inventory: RefCounted = _inventory_for_record(b)
var definition: Resource = vessel_definition(product)
if not bool(order.started):
if inventory.amount(RESOURCE_ORE) < cost:
continue
if definition.has_capability("transport") and transporters.size() >= transport_capacity():
# A hub was demolished after this was ordered, so the berth it
# was going to use is gone. Hold rather than overfill.
continue
if definition.has_capability("transport") and nearest_hub_to(b.position).is_empty():
continue
inventory.withdraw(RESOURCE_ORE, cost)
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < build_time(product):
continue
orders.pop_front()
if definition.has_capability("transport"):
# Transporters are unchanged: they take themselves to the nearest
# hub and wait for work (#19).
_station_transporter(b.position)
else:
# A finished hull launches from the crafter and physically flies to
# the mothership hangar. With no mothership there is nowhere to fly,
# so preserve the old safe fallback of holding it immediately.
var mothership: Variant = get_mothership_position()
if mothership == null:
stored_vessels.append(product)
else:
vessel_deliveries.append({"type": product, "from": b.position,
"to": mothership, "progress": 0.0})
order_completed.emit(int(b.id), product)
func queue_lab_research(lab_id: int, upgrade_id: String) -> bool:
var lab: Dictionary = find_building(lab_id)
if not building_has_capability(lab, "vessel_research") or upgrade_id == "":
return false
var orders: Array = lab.get("research_orders", [])
var unlock_definition: Resource = vessel_for_unlock(upgrade_id)
if not orders.is_empty() or (unlock_definition == null and upgrade_maxed(upgrade_id)):
return false
orders.append({"id": upgrade_id, "elapsed": 0.0, "started": false})
lab.research_orders = orders
return true
func lab_research_order(lab_id: int) -> Dictionary:
var lab: Dictionary = find_building(lab_id)
var orders: Array = lab.get("research_orders", [])
return {} if orders.is_empty() else orders[0]
func _advance_lab_research(delta: float) -> void:
for lab in buildings:
if not building_has_capability(lab, "vessel_research"):
continue
var orders: Array = lab.get("research_orders", [])
if orders.is_empty():
continue
var order: Dictionary = orders[0]
var id: String = String(order.id)
var unlock_definition: Resource = vessel_for_unlock(id)
var cost: int = unlock_definition.unlock_cost if unlock_definition != null else upgrade_cost(id)
var inventory: RefCounted = _inventory_for_record(lab)
if not bool(order.started):
if inventory.amount(RESOURCE_ORE) < cost:
continue
inventory.withdraw(RESOURCE_ORE, cost)
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < lab_research_time():
continue
if unlock_definition != null:
vessel_levels[id] = 1
else:
vessel_levels[id] = upgrade_level(id) + 1
vessel_upgraded.emit(id)
orders.pop_front()
func claim_stored_vessel(index: int) -> String:
# Hands one finished vessel over to be crewed. Returns its type, or ""
# when there is nothing there - the caller decides whether the fleet
# actually has room.
if index < 0 or index >= stored_vessels.size():
return ""
return String(stored_vessels[index])
func take_stored_vessel(index: int) -> String:
var product: String = claim_stored_vessel(index)
if product == "":
return ""
stored_vessels.remove_at(index)
return product
func has_mothership() -> bool:
return get_mothership_position() != null
func has_building_of_type(type: String) -> bool:
for building in buildings:
if building.type == type:
return true
return false
func remove_buildings_of_type(type: String) -> int:
var kept: Array = []
var removed: int = 0
for b in buildings:
if b.type == type:
removed += 1
else:
kept.append(b)
buildings = kept
return removed
func get_mothership_position() -> Variant:
# Where a wiped-out fleet gets rebuilt. Returns null when no mothership
# has been built yet, so the caller can fall back to the world origin.
for b in buildings:
if building_has_capability(b, "bank"):
return b.position
return null
# --- Fleet formation -------------------------------------------------------
# Where each vessel sits relative to the fleet centre, editable at a command
# centre. Lives here (not as a const on Fleet) so a layout the player has
# arranged survives the trip to a planet's surface and back, and so a
# rebuilt fleet keeps their arrangement.
# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet
# capacity upgrade is researched, but they are defined up front so the wedge
# keeps a deliberate shape as it grows rather than having positions invented
# at unlock time.
const DEFAULT_FORMATION: Array = [
Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32),
Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56),
]
var formation: Array = DEFAULT_FORMATION.duplicate()
func reset_formation() -> void:
formation = DEFAULT_FORMATION.duplicate()
func rotate_formation(radians: float) -> void:
# Pivots every slot about the fleet centre, keeping the shape intact
# and only changing which way it faces.
for i in range(formation.size()):
formation[i] = formation[i].rotated(radians)
# --- Extractors ------------------------------------------------------------
# Extractors mine into their OWN local stock. They do not own a magical
# hauler: ore becomes spendable only when a hub-stationed transporter picks
# up a full load and physically delivers it to the mothership. Entries are
# {"position", "planet_id", "planet_name", "resource_type", "inventory", "mining_progress"}.
const EXTRACTOR_RATE: float = 2.0 # ore per second
var extractors: Array = []
func has_extractor_at(planet_key: String) -> bool:
for e in extractors:
# Old saves have no planet_id; their name remains a migration fallback.
if String(e.get("planet_id", e.get("planet_name", ""))) == planet_key:
return true
return false
signal extractor_added(planet_name: String)
func add_extractor(planet_name: String, at_position: Vector2, resource_type: String = "ore",
planet_key: String = "") -> void:
var stable_id: String = planet_key if planet_key != "" else planet_name
extractors.append({
"position": at_position,
"planet_id": stable_id,
"planet_name": planet_name,
"resource_type": resource_type,
"inventory": INVENTORY.new(),
"mining_progress": 0.0,
})
# An extractor is built from SPACE, with the fleet parked on the planet -
# so the Planet node is already in the tree with its _ready() long since
# run. Without this it kept showing no derrick until it happened to
# despawn and respawn, which reads as the build having done nothing.
extractor_added.emit(stable_id)
func extractor_stock(index: int) -> int:
if index < 0 or index >= extractors.size():
return 0
var extractor: Dictionary = extractors[index]
return _extractor_inventory(extractor).amount(String(extractor.get("resource_type", RESOURCE_ORE)))
func _extractor_inventory(extractor: Dictionary) -> RefCounted:
var inventory: RefCounted = extractor.get("inventory") as RefCounted
if inventory == null:
inventory = INVENTORY.new()
extractor.inventory = inventory
# Old extractor state had one stock field whose material was described by
# resource_type. Absorb it into that key on first access.
if extractor.has("stock"):
inventory.set_amount(String(extractor.get("resource_type", RESOURCE_ORE)), int(extractor.stock))
extractor.erase("stock")
return inventory
func _process(delta: float) -> void:
# Runs in the autoload, so mining and transport continue while the player
# is on a planet's surface or nowhere near either.
_advance_production(delta)
_advance_lab_research(delta)
_advance_vessel_deliveries(delta)
for e in extractors:
e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE
var whole: int = int(floor(float(e.mining_progress)))
if whole > 0:
_extractor_inventory(e).deposit(String(e.get("resource_type", RESOURCE_ORE)), whole)
e.mining_progress = float(e.mining_progress) - whole
_advance_transporters(delta)
func vessel_delivery_position(delivery: Dictionary) -> Vector2:
return (delivery.from as Vector2).lerp(delivery.to as Vector2,
clampf(float(delivery.progress), 0.0, 1.0))
func _advance_vessel_deliveries(delta: float) -> void:
for i in range(vessel_deliveries.size() - 1, -1, -1):
var delivery: Dictionary = vessel_deliveries[i]
var distance: float = (delivery.from as Vector2).distance_to(delivery.to as Vector2)
delivery.progress = float(delivery.progress) + delta / maxf(distance / VESSEL_DELIVERY_SPEED, 0.001)
if float(delivery.progress) >= 1.0:
stored_vessels.append(String(delivery.type))
vessel_deliveries.remove_at(i)
# --- transporters (#19) ---------------------------------------------------
# Vessels stationed at a transport hub that fetch ore from the mothership and
# deliver it to whichever building is waiting on it.
#
# Simulated HERE rather than by any node, exactly like the extractor haulers
# above and for the same reason: a route the player is nowhere near still has
# to run. `progress` drives both the drawn position and the moment the ore
# actually moves, so what you see and what happens cannot drift apart.
#
# Entries are {"hub_id", "state", "target_id", "carrying", "progress",
# "from", "to"}.
const TRANSPORTER_SPEED: float = 340.0
# How many transporters one hub can station. Raised for EVERY hub - built
# already or not - by research, which is the same shape as max_fleet_size()
# rather than a second mechanism doing the same job.
const BASE_HUB_CAPACITY: int = 2
const TRANSPORTER_IDLE: String = "idle"
const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership"
const TRANSPORTER_TO_BUILDING: String = "to_building"
const TRANSPORTER_RETURNING: String = "returning"
const TRANSPORTER_TO_EXTRACTOR: String = "to_extractor"
const TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: String = "extractor_to_mothership"
var transporters: Array = []
func hub_capacity() -> int:
# Per hub.
var n: int = BASE_HUB_CAPACITY
for id in unlocked:
n += int(skill_node(id).get("hub_bonus", 0))
return n
func hub_count() -> int:
var n: int = 0
for b in buildings:
if building_has_capability(b, "transport_hub"):
n += 1
return n
func transport_capacity() -> int:
# Total berths: more hubs, or more capacity per hub. Either raises it,
# which is exactly the choice #12 asks the player to make.
return hub_count() * hub_capacity()
func pending_transporter_orders() -> int:
var n: int = 0
for b in buildings:
for o in b.get("orders", []):
if vessel_has_capability(String(o.product), "transport"):
n += 1
return n
func transporters_claimed() -> int:
# Built plus already ordered: an order in the queue has a berth reserved,
# or a player could queue ten of them against two berths and only find out
# when they refused to appear.
return transporters.size() + pending_transporter_orders()
func can_station_transporter() -> bool:
return transporters_claimed() < transport_capacity()
func has_transport_hub() -> bool:
return not nearest_hub_to(Vector2.ZERO).is_empty()
func nearest_hub_to(at: Vector2) -> Dictionary:
var best: Dictionary = {}
var best_d: float = INF
for b in buildings:
if not building_has_capability(b, "transport_hub"):
continue
var d: float = at.distance_squared_to(b.position)
if d < best_d:
best_d = d
best = b
return best
func _station_transporter(built_at: Vector2) -> void:
# Built at the crafter, then it flies itself to the nearest hub and waits
# there for orders (#12).
var hub: Dictionary = nearest_hub_to(built_at)
if hub.is_empty():
return
transporters.append({
"hub_id": int(hub.id), "state": TRANSPORTER_RETURNING, "target_id": 0,
"carrying": 0, "resource_type": "ore", "progress": 0.0, "from": built_at, "to": hub.position,
})
func transporter_position(t: Dictionary) -> Vector2:
return (t.from as Vector2).lerp(t.to as Vector2, clampf(t.progress, 0.0, 1.0))
func _hub_position(hub_id: int) -> Variant:
var hub: Dictionary = find_building(hub_id)
return null if hub.is_empty() else hub.position
func _begin_leg(t: Dictionary, from: Vector2, to: Vector2, state: String) -> void:
t.from = from
t.to = to
t.state = state
t.progress = 0.0
func _transporter_inbound_to(building_id: int) -> bool:
for t in transporters:
if t.state != TRANSPORTER_IDLE and int(t.target_id) == building_id:
return true
return false
func _transporter_inbound_to_extractor(index: int) -> bool:
for t in transporters:
if t.state == TRANSPORTER_TO_EXTRACTOR \
or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
if int(t.get("extractor_index", -1)) == index:
return true
return false
func _dispatch_transporters() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return # nowhere to load from
# A distant extractor is worth visiting only once it has a full hold ready.
# This keeps the transporter system meaningful: mining is local until a
# vessel physically carries a researched-sized load home.
var load: int = transporter_load_limit()
for i in range(extractors.size()):
var extractor: Dictionary = extractors[i]
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
if _extractor_inventory(extractor).amount(resource_id) < load or _transporter_inbound_to_extractor(i):
continue
var chosen_extractor: Dictionary = {}
var best_extractor_distance: float = INF
for t in transporters:
if t.state != TRANSPORTER_IDLE:
continue
var d: float = transporter_position(t).distance_squared_to(extractor.position)
if d < best_extractor_distance:
best_extractor_distance = d
chosen_extractor = t
if chosen_extractor.is_empty():
return
chosen_extractor.extractor_index = i
chosen_extractor.resource_type = resource_id
chosen_extractor.target_id = 0
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
TRANSPORTER_TO_EXTRACTOR)
for b in buildings:
if outstanding_cost(int(b.id)) <= 0:
continue
# One at a time per building: without this every idle transporter
# would be sent after the same shortfall and they would all arrive
# carrying ore nobody needs any more.
if _transporter_inbound_to(int(b.id)):
continue
var chosen: Dictionary = {}
var best_d: float = INF
for t in transporters:
if t.state != TRANSPORTER_IDLE:
continue
var here: Vector2 = transporter_position(t)
var d: float = here.distance_squared_to(b.position)
if d < best_d:
best_d = d
chosen = t
if chosen.is_empty():
return # every transporter is busy
chosen.target_id = int(b.id)
_begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP)
func _advance_transporters(delta: float) -> void:
if transporters.is_empty():
return
_dispatch_transporters()
for t in transporters:
if t.state == TRANSPORTER_IDLE:
continue
var distance: float = (t.from as Vector2).distance_to(t.to as Vector2)
var leg: float = maxf(distance / transporter_speed(), 0.0001)
t.progress += delta / leg
if t.progress < 1.0:
continue
t.progress = 1.0
_arrive(t)
func _arrive(t: Dictionary) -> void:
var here: Vector2 = t.to
var hub_pos: Variant = _hub_position(int(t.hub_id))
match t.state:
TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id))
var want: int = outstanding_cost(int(t.target_id))
var take: int = mini(mini(want, bank_inventory.amount(RESOURCE_ORE)), transporter_load_limit())
if take <= 0 or target.is_empty():
# Nothing to carry, or the building is gone. Go home empty.
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
bank_inventory.withdraw(RESOURCE_ORE, take)
t.carrying = take
_begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING)
TRANSPORTER_TO_BUILDING:
# The ore moves at exactly the moment the vessel arrives.
if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0:
# Building demolished in transit - put the ore back rather
# than quietly destroying it.
bank_inventory.deposit(RESOURCE_ORE, int(t.carrying))
t.carrying = 0
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
TRANSPORTER_TO_EXTRACTOR:
var extractor_index: int = int(t.get("extractor_index", -1))
if extractor_index < 0 or extractor_index >= extractors.size():
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
var extractor: Dictionary = extractors[extractor_index]
var load: int = transporter_load_limit()
var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE))
var extractor_inventory: RefCounted = _extractor_inventory(extractor)
if extractor_inventory.amount(resource_id) < load:
t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
extractor_inventory.withdraw(resource_id, load)
t.carrying = load
t.resource_type = resource_id
_begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR)
TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
# Bank only on arrival; cargo retains the extractor's resource type.
bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ORE)), int(t.carrying))
t.carrying = 0
t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
TRANSPORTER_RETURNING:
t.state = TRANSPORTER_IDLE
t.progress = 1.0
func can_afford(cost: int) -> bool:
return bank_inventory.amount(RESOURCE_ORE) >= cost
func spend(cost: int) -> bool:
if not can_afford(cost):
return false
bank_inventory.withdraw(RESOURCE_ORE, cost)
return true