spaceship/GameState.gd
2026-08-13 21:39:05 +02:00

1119 lines
36 KiB
GDScript

extends Node
# 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_type: String = ""
# 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 -------------------------------------------------------------
# Ore that has been BANKED at the mothership. This is the only pool anything
# can be bought with.
# Starts stocked for play-testing so buildings can be tried without mining
# first - drop this back to 0 for a real playthrough.
var rocks: int = 500
# Gas is a second, banked resource. It is deliberately separate from ore:
# construction still spends ore, while future gas technologies can consume
# gas without making the two materials silently interchangeable.
var gas: int = 0
# Ore the fleet is physically carrying and has not delivered yet (#14). Mining
# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a
# long mining run is a real trip with something to lose rather than instant
# income from anywhere on the map.
#
# Lives here, like everything else that has to survive Main.tscn being torn
# down and rebuilt on every trip to a planet's surface - and cargo mined ON a
# surface would otherwise be dropped the moment the player took off.
var fleet_cargo: int = 0
func collect_cargo(amount: int = 1) -> void:
fleet_cargo += 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:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
if b.type == BUILDING_MOTHERSHIP:
return rocks
return int(b.get("stock", 0))
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.
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var moved: int = mini(amount, fleet_cargo)
if moved <= 0:
return 0
fleet_cargo -= moved
if b.type == BUILDING_MOTHERSHIP:
rocks += moved
else:
b.stock = int(b.get("stock", 0)) + moved
return moved
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.
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var moved: int = mini(amount, building_holdings(id))
if moved <= 0:
return 0
if b.type == BUILDING_MOTHERSHIP:
rocks -= moved
else:
b.stock = int(b.get("stock", 0)) - moved
fleet_cargo += moved
return moved
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:
# The mothership is always buildable: it is where research happens, so
# locking it would leave a player with no mothership unable to ever get
# one.
if type == BUILDING_MOTHERSHIP:
return true
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, "stock": 0, "orders": [],
})
return id
func find_building(id: int) -> Dictionary:
for b in buildings:
if b.get("id", 0) == id:
return b
return {}
# --- 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"
const VESSEL_NAMES: Dictionary = {
VESSEL_INTERCEPTOR: "Interceptor",
VESSEL_SHIELD: "Shield Vessel",
VESSEL_TRANSPORTER: "Transporter",
}
const SHIELD_COST: int = 14
# How long one vessel takes to build, once its ore is actually in the
# building. A queue you can watch and cancel needs time to exist in.
const BUILD_TIMES: Dictionary = {
VESSEL_INTERCEPTOR: 10.0,
VESSEL_SHIELD: 16.0,
VESSEL_TRANSPORTER: 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:
return float(BUILD_TIMES.get(product, 10.0)) * build_time_scale
# Researched at the ship lab, per vessel type. Separate from SKILL_TREE on
# purpose: that gates what the player may build at all and is a strategic
# spine, while these are repeatable tuning on a vessel you already have.
# `cost` is the FIRST level; each level after costs cost * (level + 1), so
# the third point in something is not the same price as the first.
const VESSEL_UPGRADES: Array = [
{
"id": "int_damage", "vessel": VESSEL_INTERCEPTOR, "name": "Heavy Slugs",
"cost": 30, "max_level": 3, "desc": "+1 damage per shot.",
},
{
"id": "int_rate", "vessel": VESSEL_INTERCEPTOR, "name": "Autoloader",
"cost": 25, "max_level": 3, "desc": "Fires 15% faster per level.",
},
{
"id": "int_range", "vessel": VESSEL_INTERCEPTOR, "name": "Targeting Array",
"cost": 20, "max_level": 3, "desc": "+60 firing range per level.",
},
{
"id": "int_speed", "vessel": VESSEL_INTERCEPTOR, "name": "Thrusters",
"cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.",
},
{
"id": "shd_hits", "vessel": VESSEL_SHIELD, "name": "Layered Emitters",
"cost": 60, "max_level": 2, "desc": "+1 hit absorbed before the shield drops.",
},
{
"id": "shd_recharge", "vessel": VESSEL_SHIELD, "name": "Fast Cycling",
"cost": 35, "max_level": 3, "desc": "Shield returns 1.2s sooner per level.",
},
{
"id": "shd_speed", "vessel": VESSEL_SHIELD, "name": "Thrusters",
"cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.",
},
{
"id": "trn_capacity", "vessel": VESSEL_TRANSPORTER, "name": "Expanded Hold",
"cost": 30, "max_level": 3, "desc": "Carries 8 more ore per supply run.",
},
{
"id": "trn_speed", "vessel": VESSEL_TRANSPORTER, "name": "Freight Engines",
"cost": 30, "max_level": 3, "desc": "+70 speed on supply runs.",
},
]
# Base figures every vessel starts from, before anything is researched.
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 b.type == BUILDING_SHIP_LAB:
return true
return false
func shield_vessel_unlocked() -> bool:
# Unlocked AT the lab, by paying for it there - a vessel type rather than
# a stat, so it is a one-off rather than a level.
return int(vessel_levels.get(UNLOCK_SHIELD_VESSEL, 0)) > 0
const UNLOCK_SHIELD_VESSEL: String = "unlock_shield"
const UNLOCK_SHIELD_COST: int = 45
func unlock_shield_vessel() -> bool:
if shield_vessel_unlocked() or not has_ship_lab():
return false
if not spend(UNLOCK_SHIELD_COST):
return false
vessel_levels[UNLOCK_SHIELD_VESSEL] = 1
vessel_upgraded.emit(UNLOCK_SHIELD_VESSEL)
return true
func upgrade_def(id: String) -> Dictionary:
for u in VESSEL_UPGRADES:
if u.id == id:
return u
return {}
func upgrades_for(vessel: String) -> Array:
var out: Array = []
for u in VESSEL_UPGRADES:
if u.vessel == vessel:
out.append(u)
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
# --- what those upgrades actually come to ---------------------------------
# Read live rather than baked into a vessel when it is built, so researching
# something improves the ships already flying instead of only the next ones.
func ship_damage(vessel: String) -> int:
return SHIP_BASE_DAMAGE + (upgrade_level("int_damage") if vessel == VESSEL_INTERCEPTOR else 0)
func ship_fire_cooldown(vessel: String) -> float:
if vessel != VESSEL_INTERCEPTOR:
return SHIP_BASE_COOLDOWN
return SHIP_BASE_COOLDOWN * pow(0.85, float(upgrade_level("int_rate")))
func ship_fire_range(vessel: String) -> float:
return SHIP_BASE_RANGE + (60.0 * upgrade_level("int_range") if vessel == VESSEL_INTERCEPTOR else 0.0)
func _speed_levels(vessel: String) -> int:
if vessel == VESSEL_SHIELD:
return upgrade_level("shd_speed")
return upgrade_level("int_speed")
func ship_max_speed(vessel: String) -> float:
return SHIP_BASE_SPEED + 40.0 * _speed_levels(vessel)
func ship_acceleration(vessel: String) -> float:
return SHIP_BASE_ACCEL + 60.0 * _speed_levels(vessel)
func shield_max_charges() -> int:
return SHIELD_BASE_CHARGES + upgrade_level("shd_hits")
func shield_recharge_time() -> float:
return maxf(2.0, SHIELD_BASE_RECHARGE - 1.2 * upgrade_level("shd_recharge"))
func transporter_load_limit() -> int:
return TRANSPORTER_BASE_LOAD + 8 * upgrade_level("trn_capacity")
func transporter_speed() -> float:
return TRANSPORTER_BASE_SPEED + 70.0 * upgrade_level("trn_speed")
const PRODUCT_SHIP: String = "interceptor"
const PRODUCT_TRANSPORTER: String = "transporter"
const TRANSPORTER_COST: int = 10
func product_cost(product: String) -> int:
if product == PRODUCT_TRANSPORTER:
return TRANSPORTER_COST
if product == VESSEL_SHIELD:
return SHIELD_COST
return SHIP_COST
func can_produce(type: String) -> bool:
# Only the ship crafter builds anything. The hub STATIONS transporters,
# it does not make them (#12).
return type == BUILDING_SHIP_CRAFTER
func building_stock(id: int) -> int:
var b: Dictionary = find_building(id)
return 0 if b.is_empty() else int(b.get("stock", 0))
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):
b.stock = int(b.get("stock", 0)) + 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 - int(b.get("stock", 0)), 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
b.stock = int(b.get("stock", 0)) + amount
return 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
if product == VESSEL_SHIELD and not shield_vessel_unlocked():
return false
if product == PRODUCT_TRANSPORTER 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 product == PRODUCT_TRANSPORTER 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)
if not bool(order.started):
if int(b.get("stock", 0)) < cost:
continue
if product == PRODUCT_TRANSPORTER 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 product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty():
continue
b.stock = int(b.stock) - cost
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < build_time(product):
continue
orders.pop_front()
if product == PRODUCT_TRANSPORTER:
# 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 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 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 b.type == BUILDING_MOTHERSHIP:
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_name", "resource_type", "stock", "mining_progress"}.
const EXTRACTOR_RATE: float = 2.0 # ore per second
var extractors: Array = []
func has_extractor_at(planet_name: String) -> bool:
for e in extractors:
if e.planet_name == planet_name:
return true
return false
signal extractor_added(planet_name: String)
func add_extractor(planet_name: String, at_position: Vector2, resource_type: String = "ore") -> void:
extractors.append({
"position": at_position,
"planet_name": planet_name,
"resource_type": resource_type,
"stock": 0,
"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(planet_name)
func extractor_stock(index: int) -> int:
if index < 0 or index >= extractors.size():
return 0
return int(extractors[index].get("stock", 0))
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_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:
e.stock = int(e.get("stock", 0)) + 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 b.type == BUILDING_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 String(o.product) == PRODUCT_TRANSPORTER:
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 b.type != BUILDING_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]
if int(extractor.get("stock", 0)) < 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 = String(extractor.get("resource_type", "ore"))
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, rocks), 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
rocks -= 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.
rocks += 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()
if int(extractor.get("stock", 0)) < load:
t.extractor_index = -1
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
return
extractor.stock = int(extractor.stock) - load
t.carrying = load
t.resource_type = String(extractor.get("resource_type", "ore"))
_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.
if String(t.get("resource_type", "ore")) == "gas":
gas += int(t.carrying)
else:
rocks += 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 rocks >= cost
func spend(cost: int) -> bool:
if not can_afford(cost):
return false
rocks -= cost
return true