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 # 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 signal cargo_delivered(amount: int) func collect_cargo(amount: int = 1) -> void: fleet_cargo += amount # One press of "load ore" at the mothership. A batch rather than the whole # bank: the hold is the part that can be lost, and emptying the bank in one # tap would also strand research, which still spends from it. const CARGO_LOAD_BATCH: int = 25 func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int: # Banked ore back into the hold, so the fleet can carry it to a building # that is waiting on it (#17). var taken: int = mini(amount, rocks) if taken <= 0: return 0 rocks -= taken fleet_cargo += taken return taken func unload_into_building(id: int) -> int: # Everything the fleet is carrying, into this building's stock. Returns # how much moved so a caller can tell the player something happened. if fleet_cargo <= 0: return 0 var moved: int = fleet_cargo if deposit_to_building(id, moved) <= 0: return 0 fleet_cargo = 0 return moved func deliver_cargo() -> int: # Everything the fleet is carrying, banked at once. Returns how much moved # so a caller can react to a delivery actually having happened rather than # to merely being in range. if fleet_cargo <= 0: return 0 var delivered: int = fleet_cargo rocks += delivered fleet_cargo = 0 cargo_delivered.emit(delivered) return delivered 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.82, "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.62, "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.05, "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.65, "fleet_bonus": 1, "desc": "Permanent: +1 vessel in the fleet.", }, { "id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron", "x": 0.3, "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 # Vessels that have been paid for and built but not yet handed to the fleet, # as a list of VESSEL_* types - a shield vessel finishing while the player is # on a planet must not come back as an interceptor. Production is simulated # here, in the autoload, the same way extractor haulers are, so an order can # complete where there is no Fleet node to hand a ship to. var vessels_owed: Array = [] 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 # 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 o == product: n += 1 return n 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", []): need += product_cost(o) 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) return true func _advance_production() -> void: for b in buildings: if not can_produce(b.type): continue # `while`, not `if`: a delivery big enough to cover several queued # orders should clear them all rather than dribbling one out per # frame. var orders: Array = b.get("orders", []) while not orders.is_empty(): var product: String = String(orders[0]) var cost: int = product_cost(product) if int(b.get("stock", 0)) < cost: break 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 the order rather than # overfilling what is left. break if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty(): # Every hub was demolished while this sat in the queue. Leave # it queued and the stock untouched rather than producing a # vessel with nowhere to live. break b.stock = int(b.stock) - cost orders.pop_front() if product == PRODUCT_TRANSPORTER: _station_transporter(b.position) else: vessels_owed.append(product) order_completed.emit(int(b.id), 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 and their haulers ----------------------------------------- # An extractor sits on a planet and runs a single automated hauler back and # forth to the mothership, delivering ore on each arrival. # # The round trip is SIMULATED HERE, in the autoload, rather than by any # node: it advances whether the player is in space, on a planet's surface, # or far enough away that nothing about the route is loaded. `progress` is # a fraction of one full there-and-back cycle, so a hauler's drawn position # is derived from the same number that decides when ore is delivered - the # visual arrival and the payout cannot drift apart, because they are the # same event. # # Entries are {"position": Vector2 (the planet's position in SPACE), # "planet_name": String, "progress": float}. const HAULER_SPEED: float = 260.0 const HAULER_YIELD: int = 4 # Progress value at which the hauler reaches the mothership: exactly half # way through the cycle, the outbound leg being the first half. const HAULER_ARRIVAL: float = 0.5 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) -> void: extractors.append({ "position": at_position, "planet_name": planet_name, "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 get_hauler_route_time(extractor: Dictionary) -> float: # Full cycle: out to the mothership and back again. var mothership: Variant = get_mothership_position() if mothership == null: return 0.0 var distance: float = extractor.position.distance_to(mothership) if distance < 1.0: return 0.0 return (distance * 2.0) / HAULER_SPEED func get_hauler_position(extractor: Dictionary) -> Variant: # Where the hauler is right now, derived purely from `progress`. Returns # null when there is no route (no mothership built yet). var mothership: Variant = get_mothership_position() if mothership == null: return null var t: float = extractor.progress if t <= HAULER_ARRIVAL: return extractor.position.lerp(mothership, t / HAULER_ARRIVAL) return mothership.lerp(extractor.position, (t - HAULER_ARRIVAL) / (1.0 - HAULER_ARRIVAL)) func _process(delta: float) -> void: # Runs in the autoload, so extraction keeps earning - and queued orders # keep completing - while the player is on a planet's surface or nowhere # near either. _advance_production() _advance_transporters(delta) if extractors.is_empty(): return for e in extractors: var cycle: float = get_hauler_route_time(e) if cycle <= 0.0: continue # nothing to deliver to yet var before: float = e.progress e.progress += delta / cycle # Ore lands the instant the hauler reaches the mothership - the same # instant get_hauler_position() puts it there. if before < HAULER_ARRIVAL and e.progress >= HAULER_ARRIVAL: rocks += HAULER_YIELD while e.progress >= 1.0: e.progress -= 1.0 # --- 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" 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 o == 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, "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 _dispatch_transporters() -> void: var mothership: Variant = get_mothership_position() if mothership == null: return # nowhere to load from 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_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