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" const COMMAND_CENTER_COST: int = 15 const EXTRACTOR_COST: int = 25 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": "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() # --- 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 # Ships that have been paid for and built but not yet handed to the fleet. # Production is simulated here, in the autoload, the same way extractor # haulers are - so an order can complete while the player is down on a # planet's surface, where there is no Fleet node to hand a ship to. The fleet # claims them when it next can. var ships_owed: int = 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, "queued": 0, }) return id func find_building(id: int) -> Dictionary: for b in buildings: if b.get("id", 0) == id: return b return {} func production_cost(type: String) -> int: # What one order out of this building costs it. 0 means it produces # nothing and cannot be ordered from. if type == BUILDING_SHIP_CRAFTER: return SHIP_COST return 0 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 int(b.get("queued", 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) -> bool: var b: Dictionary = find_building(id) if b.is_empty() or production_cost(b.type) <= 0: return false b.queued = int(b.get("queued", 0)) + 1 return true func _advance_production() -> void: for b in buildings: var cost: int = production_cost(b.type) if cost <= 0: continue # `while`, not `if`: a delivery big enough to cover several queued # orders should clear them all rather than dribbling one out per # frame. while int(b.get("queued", 0)) > 0 and int(b.get("stock", 0)) >= cost: b.stock = int(b.stock) - cost b.queued = int(b.queued) - 1 if b.type == BUILDING_SHIP_CRAFTER: ships_owed += 1 order_completed.emit(int(b.id), String(b.type)) 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() 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 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