extends Node const INVENTORY = preload("res://resources/Inventory.gd") const RESOURCE_DEFINITIONS = preload("res://resources/ResourceDefinitions.gd") const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd") const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd") const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd") # By path, not by its `WorldGen` global name: an autoload compiles before the # global class table is necessarily populated, the same hazard PlanetSetups.gd # works around. const WORLD_GEN = preload("res://world/WorldGen.gd") const QUESTS = preload("res://core/Quests.gd") const CATALOG_CHECK = preload("res://core/CatalogCheck.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 = STARTING_SHIP_COUNT var surviving_vessel_types: PackedStringArray = PackedStringArray() # #106: a mirror of what is flying, pushed by Fleet._sync_composition_state() # whenever the composition changes. GameState cannot see scene nodes, and the # census the Planning Center works from has to include the fleet. var fleet_vessel_types: PackedStringArray = PackedStringArray() # Where the fleet is, written by Fleet each frame. Only #158 needs it: a # building may draw on the hold when the hold is actually there, and this # autoload has no other way to know. var fleet_position: Vector2 = Vector2.ZERO # #122: the prologue is running. Set by reset_new_game() so it plays once at # the start of a NEW game and never on a resumed one, and cleared by the # director when the prologue hands over. # # It is a flag rather than a separate scene because the prologue DIRECTS the # real game: the real fleet, the real enemies, the real asteroid field. That # is also what makes "then the game starts" cost nothing - the director stops # steering and there is nothing left to load. var intro_active: bool = false # #173: what the startup catalog check found, if anything. Empty in a healthy # build; see CatalogCheck for why a build can be unhealthy without anything # else noticing. var catalog_faults: PackedStringArray = PackedStringArray() # #124: the breach takes the wheel. Movement input is ignored while this is # set, so the fleet goes where the script sends it rather than fighting the # player for the last few seconds of the prologue. var intro_locks_movement: bool = false # Short in-world reveals (currently the boss arrival) use their own lock so # they do not pretend the prologue is active merely to steer the fleet for a # few seconds. var encounter_locks_movement: bool = false # #123: the prologue's convoy is bigger than any fleet the player can field # yet - it is not their fleet, it is the one they are about to lose. An # explicit override rather than a fake doctrine, so nothing about the # player's own progression has to be bent to fit a cutscene. var intro_fleet_size: int = 0 # --- what the player is actually holding (#171) ----------------------------- # # "The web export should auto detect keyboard mouse or screen and remove the # touch screen controls if not needed." # # DisplayServer.is_touchscreen_available() is the wrong question in a browser # and always was. It answers "could a touch arrive", and a desktop browser on # a laptop with a touch-capable panel - or one that merely reports # navigator.maxTouchPoints - says yes. So the thumb sticks and the pivot pair # sat over a screen being played with a mouse, and mouse aiming, which the # same query gates, was switched off on the machine it exists for. # # The right question is what the player LAST actually used, which is a fact # rather than a capability, and it is asked here rather than in a controller # because two of them need the same answer: GameController shows or hides the # on-screen controls, and MouseCombatController decides whether a click fires. # One answer, so they can never disagree. # # The catch is that Godot can manufacture the other kind of event from each # one - the pointing/emulate_* settings in project.godot turn taps into clicks # and clicks into taps. Taken at face value a phone would look like it had a # mouse the instant a finger touched the glass. So a touch SUPPRESSES mouse # evidence for a moment afterwards: a manufactured click always follows its # touch within the same frame, well inside the window, while a player who # genuinely puts a finger down and then reaches for the mouse takes far longer # than this to do it. Correct whether that emulation is on or off - with it # off there is simply nothing for the window to suppress. const POINTER_TOUCH_GRACE_MSEC: int = 1000 # Seeded from the display's own answer so the very first frame is sensible on # a phone, and immediately overwritten by the first real input either way. var pointer_is_touch: bool = DisplayServer.is_touchscreen_available() # When a touch was last seen, in engine milliseconds. -1 for never. Public so # a test can age it out rather than sleeping for a second. var last_touch_msec: int = -1 func note_touch_input() -> void: last_touch_msec = Time.get_ticks_msec() pointer_is_touch = true # A mouse or a key. Ignored while a touch is still recent, because that is # what a manufactured event looks like. func note_pointer_input() -> void: if last_touch_msec >= 0 \ and Time.get_ticks_msec() - last_touch_msec < POINTER_TOUCH_GRACE_MSEC: return pointer_is_touch = false 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" # #59: 1.0 is today's behaviour. Applied at the point of use (Fleet's cruise # anchor, Ship's own max_speed/acceleration) rather than baked into stored # stats, so a speed upgrade researched while boosted can't corrupt the base # value - see Fleet.gd/Ship.gd for the multiply sites. # The ceiling ONE Energy Propulser reaches. Every DOUBLING of them raises it # by another 1.0 - see set_distorder_vessel_count(). Kept as the one-vessel # figure rather than an absolute cap so the fader's meaning at a single # vessel is unchanged. const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0 # How far the fader can actually go right now, given the fleet's current # composition. Pushed by Fleet whenever that composition changes, the same # direction as every other fleet-derived figure here - GameState does not # reach into the scene tree to count ships. var max_fleet_speed_multiplier: float = MAX_FLEET_SPEED_MULTIPLIER var fleet_speed_multiplier: float = 1.0 # #62: energy cost of sustaining a boost, scaled by how far above 1.0 the # multiplier is - a small boost costs proportionally less, full boost costs # ENERGY_DRAIN_PER_SECOND. Sitting at exactly 1.0 costs nothing. const ENERGY_DRAIN_PER_SECOND: float = 2.0 var _distorder_drain_progress: float = 0.0 # One-shot notice, consumed and cleared by SpeedFader.gd: true the instant # a sustained boost is force-reset for running the fleet hold's energy # to zero, so the control (which may not even be part of the current # scene tree at that exact frame) can show why on its next _process(). var boost_depleted_notice: bool = false func set_fleet_speed_multiplier(value: float) -> void: fleet_speed_multiplier = clampf(value, 1.0, max_fleet_speed_multiplier) # Every distorder vessel past the first widens the fleet's speed ceiling by # 1.0, so stacking them is worth doing rather than the second one being dead # weight. Zero vessels returns the ceiling to its one-vessel value; the # multiplier itself is reset separately by Fleet when the last one is lost. # # Re-clamps the CURRENT multiplier, so losing a distorder vessel mid-boost # pulls the fleet down to the new ceiling instead of leaving it travelling # faster than its remaining crew can sustain. # #77: the ceiling grows with the LOGARITHM of how many propulsers fly, not # linearly. Each one used to add a flat 1.0, so a fleet that could afford six # of them simply drove at six times cruise and the vessel stopped being a # choice. Base-2, which makes the rule one sentence: every DOUBLING of # propulsers buys another 1.0. # # 1 -> 3.0 2 -> 4.0 4 -> 5.0 8 -> 6.0 # # Two propulsers are worth exactly what they were worth before, so nothing a # player has already built is devalued; it is the fifth and sixth that stop # paying for themselves. func set_distorder_vessel_count(count: int) -> void: if count <= 1: max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER return max_fleet_speed_multiplier = MAX_FLEET_SPEED_MULTIPLIER \ + log(float(count)) / log(2.0) set_fleet_speed_multiplier(fleet_speed_multiplier) # #63: currency gated behind conquering a research_lab planet - required by # both progression paths (skill tree nodes, vessel/upgrade research) on top # of their existing ore/resource costs. signal research_points_changed(total: int) var research_points: int = 0 # Set only by start_dev_game(), which the main menu offers only when running # from Godot. Makes WorldGen plant one of every planet type around the spawn. # Cleared by reset_new_game(), so starting a normal game after a dev one # cannot leave a dev world behind. # # The setter mirrors it into WorldGen, which cannot read it back: world # generation is static and dependency-free by design. This stays the single # place to set it - assigning dev_mode anywhere, tests included, reaches # world generation. var dev_unlimited_resources: bool = false # #202: every research and production price answers zero while this is on. # A separate switch from the one above rather than a stronger version of it - # unlimited resources tests whether a price can be PAID, and this tests what # happens when there is no price at all, which is a different play-test. var dev_free_costs: bool = false var dev_mode: bool = false: set(value): dev_mode = value if not value: dev_unlimited_resources = false dev_free_costs = false WORLD_GEN.dev_planets_enabled = value # 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 = {} # The scanner's latest unconquered destination. Unlike explored_chunks this # is one intentional navigation target, shared by the map and flight HUD. var scanner_target: Dictionary = {} # #207: where the prologue's convoy is headed. Set by IntroDirector while the # World Ship is still something to fly TOWARD, and cleared the moment the # breach takes that away. Kept beside the scanner's target because the HUD # asks one question - "where am I going" - and only ever wants one answer. var intro_destination: Dictionary = {} # The four ordinary destinations teach the scanner enough signatures to # identify the hostile boss world. Kept explicit so adding a sixth authored # planet later cannot silently move the progression gate. const SCANNER_SURVEY_TYPES: PackedStringArray = [PLANET_DEFINITIONS.ROCKY_ID, PLANET_DEFINITIONS.GAS_GIANT_ID, PLANET_DEFINITIONS.BLACK_HOLE_ID, PLANET_DEFINITIONS.RESEARCH_LAB_ID] # --- Resources ------------------------------------------------------------- const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID const RESOURCE_ENERGY: String = RESOURCE_DEFINITIONS.ENERGY_ID const RESOURCE_DARK_MATTER: String = RESOURCE_DEFINITIONS.DARK_MATTER_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. # Starts EMPTY. A new game used to open with 500 ore banked, which quietly # skipped the opening of the game - the first mining run is what teaches the # fleet hold, the return trip and the mothership. Dev mode (start_dev_game) # is where a stocked start belongs. var bank_inventory: RefCounted = INVENTORY.new() 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) # Both return the amount actually accepted (#56: the fleet hold now has a # finite total_capacity), so a collector can tell a full hold apart from a # successful pickup instead of assuming everything offered was taken. func collect_cargo(amount: int = 1) -> int: return fleet_inventory.deposit(RESOURCE_ORE, amount) func collect_gas_cargo(amount: int = 1) -> int: return 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) # #60: consumed outright (ammunition), not moved to another inventory like # transfer_resource_to_building/_fleet - returns the amount actually taken. func withdraw_fleet_resource(resource_type: String, amount: int) -> int: return fleet_inventory.withdraw(resource_type, amount) # #61: generic counterparts, for resources (energy) with no dedicated # fleet_cargo/fleet_gas_cargo-style property of their own. Deliberately NOT # the same as deposit_resource_to_building() on a "bank" building - that # deposits into the building's OWN per-record inventory, not bank_inventory # itself (only building_resource()/building_inventory() special-case "bank" # capability to redirect there). # #210: anything that reaches the fleet's own hold is heard - a mined rock, a # transporter's delivery, a load lifted off a planet. The bank is deliberately # silent: that is a number moving in a building the player is not standing in. const PICKUP_SOUND_INTERVAL_MS: int = 90 var _pickup_sound_at: int = 0 func deposit_fleet_resource(resource_type: String, amount: int) -> int: var accepted: int = fleet_inventory.deposit(resource_type, amount) if accepted > 0: _play_pickup_sound() return accepted # One unit at a time is how mining arrives, so a blip per unit would be a # machine-gun. A burst inside the window is heard once; anything slower than # it stays a separate sound. func _play_pickup_sound() -> void: var now: int = Time.get_ticks_msec() if now - _pickup_sound_at < PICKUP_SOUND_INTERVAL_MS: return _pickup_sound_at = now Sfx.play(get_tree(), Sfx.PICKUP) func deposit_bank_resource(resource_type: String, amount: int) -> int: return bank_inventory.deposit(resource_type, amount) # #56: total across every resource type in the hold, and the combined cap # that limits it - a HUD readout wants both, not any one resource's amount. func fleet_cargo_used() -> int: return fleet_inventory.total() func fleet_cargo_total_capacity() -> int: return fleet_inventory.total_capacity func _inventory_for_record(record: Dictionary) -> RefCounted: var inventory: RefCounted = record.get("inventory") as RefCounted if inventory == null: inventory = INVENTORY.new() record.inventory = inventory # An outpost holds exactly what its garrison can use, and the garrison # changes. Re-applied on every access rather than when the crew changes, # because there is no single place a crew change goes through - a vessel # arriving, one being released and one being shot down are three. if is_outpost(record): # Every resource gets a ceiling, not only the ones a magazine wants: # an outpost is not a depot, and ore parked at one has nothing to do # there and no transporter coming for it. var capacities: Dictionary = {} var magazines: Dictionary = garrison_magazines(int(record.id)) for resource_type in resource_types(): capacities[resource_type] = int(magazines.get(resource_type, 0)) inventory.resource_capacities = capacities # 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 SHIP_COST: int = 6 # #138: the per-building cost constants that used to sit here are gone. A # structure's price lives in its own BuildingDefinition and is read through # building_costs(), which is the only thing that knows about the resources # beyond ore that some of them now want. Two places holding the same number # is how they end up disagreeing. # --- 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" # #102: a forward position the fleet can garrison, researched early because # it is the answer to waves going after what you have built. const BUILDING_OUTPOST: String = "outpost" # #106: where standing orders for the whole fleet are kept. const BUILDING_PLANNING_CENTER: String = "planning_center" # #116: repairs what the waves knock down. const BUILDING_WORKSHOP: String = "spacial_workshop" # 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" # Converts gas into energy, both stored/moved through the building's own # inventory exactly like every other building (#54). const BUILDING_GAS_BURNER: String = "gas_burner" # #219: the structure that pays for a berth in the fleet. const BUILDING_FLEET_OFFICE: String = "fleet_office" # A map-revealing structure, not a passive sensor bonus. Its menu searches # the same deterministic WorldGen catalogue that PlanetField spawns from. const BUILDING_PLANET_SCANNER: String = "planet_scanner" 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). # #63: research_points (default 0 where omitted) is a SEPARATE currency from # cost (ore), spent only from GameState.research_points - earned by # conquering a research_lab planet, never bought. Balance, decided # deliberately: shipyards and logistics stay at 0 so a fresh game can still # reach a ship crafter and an extractor with zero research points (see the # closing comment on #63 for the reasoning); every other node - none of them # required for that opening - costs at least 1. # LANES. A chain keeps one x as it rises, and a branch fans out once, at the # node it branches from. That rule is why squadron was moved in #70 - it sat # in someone else's lane at the same depth and the two nodes were drawn on top # of each other - and #219 broke it again by deleting the whole doctrine lane, # leaving the ship-lab branch zigzagging between 0.82 and 0.94 with nothing # beside it. # # 0.08 supply logistics -> freight -> freight_yards # 0.25 industry power_systems -> field_engineering -> drone_bays # 0.42 spine shipyards -> coordination -> deep_space_scanning # 0.58 fleet berth_authority # 0.75 ground forward_bases -> garrison_bays # 0.92 vessels vessel_science -> logistics_command const SKILL_TREE: Array = [ { "id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "", "x": 0.42, "unlocks": BUILDING_SHIP_CRAFTER, "desc": "Build ship crafters, and buy extra vessels at them.", }, { # Fleet size is a researched thing again, but only the STRUCTURE is: # the office still has to be built and supplied, which is what #219 # moved it to. Ore only, and no research points: points come solely # from conquering a planet, and conquering one needs a fleet, so # pricing this in them would lock the fleet at its opening hull # behind a fight that hull has to win first. "id": "berth_authority", "name": "Berth Authority", "cost": 20, "requires": "shipyards", "x": 0.58, "unlocks": BUILDING_FLEET_OFFICE, "desc": "Build Fleet Offices. Each holds one more berth in the fleet, and has to be kept supplied or the vessel in it is lost.", }, { "id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards", "x": 0.08, "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.42, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1, "desc": "Build command centers, where the fleet formation is arranged.", }, { # Early on purpose (#97: "pretty early in the tech tree"). It hangs off # coordination rather than off a deep branch, so the answer to waves # attacking your structures is available at about the time the waves # start being able to hurt them. "id": "forward_bases", "name": "Forward Bases", "cost": 30, "requires": "coordination", "costs": {"ore": 30, "gas": 12}, "x": 0.75, "unlocks": BUILDING_OUTPOST, "research_points": 1, "desc": "Build outposts: forward positions vessels can be stationed at, to hold ground you are not standing on.", }, { # The same shape as freight_yards for hubs: a permanent bonus that # applies to outposts already standing, not only to new ones, so # researching it never means demolishing and rebuilding. "id": "garrison_bays", "name": "Garrison Bays", "cost": 110, "requires": "forward_bases", "costs": {"ore": 110, "gas": 35}, "x": 0.75, "outpost_bonus": 1, "research_points": 2, "icon": 40, "desc": "Permanent: +1 berth at every outpost, including ones already built.", }, { # Deep on purpose, unlike forward_bases: standing orders are a thing # you want once the fleet is large enough that keeping track of it by # hand has become work, and it has shipyards behind it because it # manages what shipyards do. "id": "logistics_command", "name": "Logistics Command", "cost": 140, "costs": {"ore": 140, "gas": 40}, "requires": "vessel_science", "x": 0.92, "unlocks": BUILDING_PLANNING_CENTER, "research_points": 3, "desc": "Build a planning center: set how many of each vessel should always exist, and the crafters are kept working until they do.", }, { # Behind power_systems rather than off the fleet branch: a workshop # is infrastructure that looks after infrastructure, and it should # arrive after the player has some worth looking after. "id": "field_engineering", "name": "Field Engineering", "cost": 95, "costs": {"ore": 95, "gas": 35, "energy": 20}, "requires": "power_systems", "x": 0.25, "unlocks": BUILDING_WORKSHOP, "research_points": 2, "desc": "Build spacial workshops. Their drones fly out and repair damaged structures, spending ore delivered to the workshop.", }, { # Same shape as freight_yards and garrison_bays: a permanent bonus # that reaches workshops already standing. "id": "drone_bays", "name": "Drone Bays", "cost": 150, "costs": {"ore": 150, "gas": 50, "energy": 35}, "requires": "field_engineering", "x": 0.25, "workshop_bonus": 1, "research_points": 2, "icon": 43, "desc": "Permanent: +1 berth at every spacial workshop, including ones already built. The drone to fill it is still yours to build.", }, { "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", "costs": {"ore": 40, "gas": 15}, "x": 0.08, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1, "desc": "Build transport hubs. Transporters are built at a ship crafter and assigned to a hub, which then runs its routes without you.", }, { "id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards", "costs": {"ore": 35, "gas": 10}, "x": 0.92, "unlocks": BUILDING_SHIP_LAB, "research_points": 1, "desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.", }, { "id": "deep_space_scanning", "name": "Deep-Space Scanning", "cost": 50, "costs": {"ore": 50, "gas": 15}, "requires": "coordination", "x": 0.42, "unlocks": BUILDING_PLANET_SCANNER, "research_points": 1, "desc": "Build a planet scanner and reveal the nearest world of a chosen type.", }, { "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", "costs": {"ore": 80, "gas": 30}, "x": 0.08, "hub_bonus": 1, "research_points": 2, "icon": 12, "desc": "Permanent: +1 berth at every hub, including ones already built. The transporter to fill it is still yours to build.", }, { "id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight", "costs": {"ore": 45, "gas": 35}, "x": 0.25, "unlocks": BUILDING_GAS_BURNER, "research_points": 2, "desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.", }, ] # How many SLOTS the fleet has before a single Fleet Office is built. # # #219 replaced the fleet-size doctrines with a structure that has to be # supplied: every berth past the first is a Fleet Office standing somewhere, # paying for the vessel in it. So this is 1 - the hull the survivors came out # of the breach with, which the ticket exempts from needing an office at all. # # Distinct from STARTING_SHIP_COUNT below: this is capacity, that is how many # vessels are actually flying. const BASE_FLEET_SIZE: int = 1 # A new game opens with a SINGLE vessel. Building the fleet up is the early # game, so the two empty slots are the first thing a ship crafter is for. const STARTING_SHIP_COUNT: int = 1 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) # What one doctrine costs. Same rule as buildings and vessels (#138): the # node's own dictionary when it has one, its ore figure otherwise. # # `cost` stays the ore figure either way rather than being folded into the # dictionary and deleted, so the tree's own display - and everything that # ever compared two nodes by price - keeps working on a plain number. func skill_costs(id: String) -> Dictionary: var node: Dictionary = skill_node(id) if node.is_empty() or costs_are_free(): return {} var costs: Dictionary = node.get("costs", {}) if not costs.is_empty(): return costs.duplicate() return {RESOURCE_ORE: int(node.cost)} if int(node.cost) > 0 else {} func can_unlock(id: String, payment_position: Variant = null) -> bool: var node: Dictionary = skill_node(id) return not is_unlocked(id) and prerequisite_met(id) \ and can_afford_resources(skill_costs(id), payment_position) \ and research_points >= skill_research_point_cost(id) func skill_research_point_cost(id: String) -> int: return 0 if costs_are_free() else int(skill_node(id).get("research_points", 0)) func unlock(id: String, payment_position: Variant = null) -> bool: if not can_unlock(id, payment_position): return false var node: Dictionary = skill_node(id) if not spend_resources(skill_costs(id), payment_position): return false var points_needed: int = skill_research_point_cost(id) if points_needed > 0: research_points -= points_needed research_points_changed.emit(research_points) unlocked.append(id) return true # #112: the mark drawn inside a skill node. A node that unlocks a structure # borrows that structure's own menu icon, so the tree and the build menu # cannot disagree about what a shipyard looks like; the rest - doctrines and # permanent bonuses, which have no building to point at - carry an explicit # one. -1 means "no mark", and the node is drawn hollow as it always was. func skill_node_icon(node: Dictionary) -> int: if node.is_empty(): return -1 var unlocks: String = String(node.get("unlocks", "")) if unlocks != "": var definition: Resource = building_definition(unlocks) if definition != null: return int(definition.menu_icon) return int(node.get("icon", -1)) func is_building_unlocked(type: String) -> bool: for node in SKILL_TREE: if node.get("unlocks", "") == type: return is_unlocked(node.id) return true # The doctrine that unlocks a structure, BY ID - the handle everything else # in the tree is keyed on (skill_depth, skill_costs, is_unlocked). func research_node_for_building(type: String) -> String: for node in SKILL_TREE: if node.get("unlocks", "") == type: return String(node.id) return "" # The same thing by NAME, which is what a message shows the player. Kept # apart from the id above because the two are easy to mix up and the mistake # is silent: skill_depth() of a display name finds no node and answers 0, so # a deep structure reads as an ungated one. func research_for_building(type: String) -> String: var id: String = research_node_for_building(type) return "" if id == "" else String(skill_node(id).name) # #219: every berth past the first is a Fleet Office standing somewhere, and # an office only counts while it can still pay for the vessel in it. A starved # one has already had its vessel destroyed; leaving it counting would let the # player rebuild straight into a berth nothing is supplying. signal fleet_upkeep_failed(slot_index: int, vessel_type: String) # How often an office is charged, and how much of the vessel's own build price # it charges. Derived from the build cost rather than authored per vessel: a # hull that was expensive to make is expensive to keep, which is the rule the # ticket asks for and needs no second table to fall out of step with. const FLEET_OFFICE_UPKEEP_SECONDS: float = 25.0 const FLEET_OFFICE_UPKEEP_DIVISOR: int = 8 # #220: and each berth costs more than the one before it. A flat per-vessel # charge means a big fleet is only linearly harder to keep, so the tenth hull # is no more of a decision than the second; compounding makes every addition # weigh against the whole fleet behind it. # # Deliberately shallow - the ticket asks for a slow one. At 1.12 the thirteenth # berth costs about three and a half times the second, and the twentieth about # seven and a half. A steeper rate reaches "unaffordable" while the player is # still learning what the fleet is for. const FLEET_UPKEEP_GROWTH: float = 1.12 # Offices in the order they were built, which is the order they are matched to # the fleet: the first vessel needs none, the second is kept by the first # office, and so on. func fleet_offices() -> Array: var offices: Array = [] for building in buildings: if String(building.get("type", "")) == BUILDING_FLEET_OFFICE: offices.append(building) return offices func supplied_fleet_office_count() -> int: var n: int = 0 for office in fleet_offices(): if not bool(office.get("starved", false)): n += 1 return n # What one berth costs per charge, for the vessel actually sitting in it. An # empty answer means this vessel is free to keep - which is true of nothing # the crafter builds, and is what an unknown type falls back to. # What one berth costs per charge. `slot_index` is the fleet position it # keeps, so the first berth past the free one pays the base rate and every # berth after it pays the compounded one (#220). func fleet_office_upkeep(vessel_type: String, slot_index: int = BASE_FLEET_SIZE) -> Dictionary: var upkeep: Dictionary = {} var scale: float = fleet_upkeep_scale(slot_index) for resource_type in product_costs(vessel_type): var share: int = int(ceil(float(product_costs(vessel_type)[resource_type]) / float(FLEET_OFFICE_UPKEEP_DIVISOR) * scale)) if share > 0: upkeep[resource_type] = share return upkeep # The multiplier on a berth at this fleet position. The free hull and the # first paid berth both sit at 1.0, so the curve starts where the charging # starts rather than partway up it. func fleet_upkeep_scale(slot_index: int) -> float: return pow(FLEET_UPKEEP_GROWTH, maxi(slot_index - BASE_FLEET_SIZE, 0)) # The whole bill, berth by berth, for the readout #220 asks for: what each # one is keeping, what it is charged, and what that is per minute. Computed # here rather than in the menu so the numbers shown are the numbers charged - # a readout that recomputes them separately can disagree with the loop that # actually takes the resources. func fleet_upkeep_breakdown() -> Dictionary: var berths: Array = [] var per_minute: Dictionary = {} var charges_per_minute: float = 60.0 / FLEET_OFFICE_UPKEEP_SECONDS var offices: Array = fleet_offices() for i in range(offices.size()): var slot_index: int = i + BASE_FLEET_SIZE var vessel_type: String = fleet_office_vessel(slot_index) if vessel_type == "": continue # a berth standing empty is charged nothing var costs: Dictionary = fleet_office_upkeep(vessel_type, slot_index) berths.append({ "slot": slot_index + 1, "vessel_type": vessel_type, "scale": fleet_upkeep_scale(slot_index), "costs": costs, "starved": bool(offices[i].get("starved", false)), }) for resource_type in costs: per_minute[resource_type] = float(per_minute.get(resource_type, 0.0)) \ + float(costs[resource_type]) * charges_per_minute return { "berths": berths, "per_minute": per_minute, "charge_seconds": FLEET_OFFICE_UPKEEP_SECONDS, "divisor": FLEET_OFFICE_UPKEEP_DIVISOR, "growth": FLEET_UPKEEP_GROWTH, } # The vessel an office is answerable for, or "" when the fleet is smaller than # the berths built for it - an office with nothing in it costs nothing. func fleet_office_vessel(slot_index: int) -> String: if slot_index < 0 or slot_index >= fleet_vessel_types.size(): return "" return String(fleet_vessel_types[slot_index]) func _advance_fleet_office_upkeep(delta: float) -> void: var offices: Array = fleet_offices() for i in range(offices.size()): var office: Dictionary = offices[i] # Office i keeps the (i + 1)th vessel: the first one needs no office. var vessel_type: String = fleet_office_vessel(i + BASE_FLEET_SIZE) if vessel_type == "": # Nothing to keep. The clock is not advanced either, so a berth # that stood empty does not present a bill the moment it is used. # # An empty berth is IDLE, not starved, including one that just # lost its vessel: an office is only failing while there is # something in it to fail. Holding it disabled would leave the # player unable to use the berth they paid for, with no way back # - it can never pay for a vessel it is not allowed to hold. office.upkeep_progress = 0.0 office.starved = false continue var progress: float = float(office.get("upkeep_progress", 0.0)) + delta if progress < FLEET_OFFICE_UPKEEP_SECONDS: office.upkeep_progress = progress continue office.upkeep_progress = progress - FLEET_OFFICE_UPKEEP_SECONDS var upkeep: Dictionary = fleet_office_upkeep(vessel_type, i + BASE_FLEET_SIZE) var inventory: RefCounted = _inventory_for_record(office) var affordable: bool = true for resource_type in upkeep: if inventory.amount(String(resource_type)) < int(upkeep[resource_type]): affordable = false break if affordable: for resource_type in upkeep: inventory.withdraw(String(resource_type), int(upkeep[resource_type])) # A restocked office goes back to work on its own: the player # fixing their supply line should not also have to rebuild it. office.starved = false continue office.starved = true say_quest_line(("A Fleet Office has run dry. The %s berthed there is gone - " + "nothing is reaching it, and a berth nobody supplies keeps nobody.") % vessel_name(vessel_type)) fleet_upkeep_failed.emit(i + BASE_FLEET_SIZE, vessel_type) func max_fleet_size() -> int: if intro_fleet_size > 0: return mini(intro_fleet_size, DEFAULT_FORMATION.size()) return mini(BASE_FLEET_SIZE + supplied_fleet_office_count(), DEFAULT_FORMATION.size()) func reset_progression() -> void: unlocked.clear() vessel_levels.clear() # Called from the main menu's Play button (#47): the autoload survives # change_scene_to_file(), so without this a second playthrough in the same # process would start from wherever the last one left off instead of a # fresh game. func reset_new_game() -> void: intro_active = true intro_locks_movement = false encounter_locks_movement = false intro_fleet_size = 0 has_return_position = false surviving_ship_count = STARTING_SHIP_COUNT surviving_vessel_types = PackedStringArray() fleet_vessel_types = PackedStringArray() fleet_position = Vector2.ZERO return_world_position = Vector2.ZERO return_camera_zoom = 1.0 planet_name = "" planet_id = "" planet_definition_id = "rocky" fleet_speed_multiplier = 1.0 _distorder_drain_progress = 0.0 boost_depleted_notice = false research_points = 0 dev_unlimited_resources = false dev_free_costs = false dev_mode = false explored_chunks.clear() scanner_target.clear() intro_destination.clear() bank_inventory = INVENTORY.new() fleet_inventory = INVENTORY.new() buildings.clear() next_building_id = 1 stored_vessels.clear() vessel_deliveries.clear() extractors.clear() transporters.clear() repair_drones.clear() planet_capture.clear() vessel_targets.clear() reset_waves() reset_quests(true) reset_progression() reset_formation() func scanner_can_find_boss() -> bool: for definition_id in SCANNER_SURVEY_TYPES: if not visited_planet_kinds.has(definition_id): return false return true func scanner_available_planet_types() -> PackedStringArray: var available: PackedStringArray = SCANNER_SURVEY_TYPES.duplicate() if scanner_can_find_boss(): available.append(PLANET_DEFINITIONS.BOSS_ID) return available # Finds the nearest unconquered generated planet of this type, reveals its # neighbourhood on the persistent map, and retains it as the flight target. # Rings bound the work, but candidates are compared by their actual generated # world position: the first planet in the first matching chunk ring is not # necessarily the physically closest one when local positions differ. func scan_for_planet(definition_id: String, origin_world_position: Vector2) -> Dictionary: if not structure_exists(BUILDING_PLANET_SCANNER) \ or definition_id not in scanner_available_planet_types(): return {} var origin: Vector2i = WORLD_GEN.chunk_coords(origin_world_position) var best: Dictionary = {} var best_distance: float = INF for radius in range(0, 257): if radius == 0: var center: Dictionary = _scanner_candidate_at(origin, definition_id, radius) if not center.is_empty(): best = center best_distance = origin_world_position.distance_to( Vector2(center.world_position)) else: # Only the perimeter is new at each radius. Every matching candidate # on it is considered before deciding whether the search can stop. for x in range(-radius, radius + 1): for y in [-radius, radius]: var horizontal: Dictionary = _scanner_candidate_at( origin + Vector2i(x, y), definition_id, radius) var horizontal_distance: float = INF if horizontal.is_empty() else \ origin_world_position.distance_to(Vector2(horizontal.world_position)) if horizontal_distance < best_distance: best = horizontal best_distance = horizontal_distance for y in range(-radius + 1, radius): for x in [-radius, radius]: var vertical: Dictionary = _scanner_candidate_at( origin + Vector2i(x, y), definition_id, radius) var vertical_distance: float = INF if vertical.is_empty() else \ origin_world_position.distance_to(Vector2(vertical.world_position)) if vertical_distance < best_distance: best = vertical best_distance = vertical_distance if not best.is_empty() and best_distance <= _scanner_unsearched_distance( origin, origin_world_position, radius): return _accept_scanner_target(best) return _accept_scanner_target(best) if not best.is_empty() else {} func _scanner_candidate_at(chunk: Vector2i, definition_id: String, distance_chunks: int) -> Dictionary: if not WORLD_GEN.has_planet_at_chunk(chunk): return {} var data: Dictionary = WORLD_GEN.planet_data_at_chunk(chunk) if String(data.get("definition_id", "")) != definition_id \ or is_planet_conquered(String(data.get("planet_id", ""))): return {} data["chunk"] = chunk data["world_position"] = WORLD_GEN.chunk_origin(chunk) + Vector2(data.local_pos) data["distance_chunks"] = distance_chunks return data func _scanner_unsearched_distance(origin_chunk: Vector2i, origin_world_position: Vector2, radius: int) -> float: var top_left: Vector2 = WORLD_GEN.chunk_origin( origin_chunk - Vector2i(radius, radius)) var bottom_right: Vector2 = WORLD_GEN.chunk_origin( origin_chunk + Vector2i(radius + 1, radius + 1)) return minf(minf(origin_world_position.x - top_left.x, bottom_right.x - origin_world_position.x), minf( origin_world_position.y - top_left.y, bottom_right.y - origin_world_position.y)) func _accept_scanner_target(data: Dictionary) -> Dictionary: var chunk := Vector2i(data.chunk) for reveal_y in range(-1, 2): for reveal_x in range(-1, 2): explored_chunks[chunk + Vector2i(reveal_x, reveal_y)] = true scanner_target = data.duplicate(true) return data # What the flight HUD should point at. The prologue's destination wins while # it exists: during those beats the fleet has no scanner and exactly one place # to be, and afterwards there is no World Ship left to point at. func active_guidance_target() -> Dictionary: if intro_active: return intro_destination return active_scanner_target() func active_scanner_target() -> Dictionary: if scanner_target.is_empty() \ or is_planet_conquered(String(scanner_target.get("planet_id", ""))): return {} return scanner_target # Amounts a dev game opens with: enough of everything to reach any part of # the game immediately, without being so large that costs stop registering. const DEV_RESOURCE_AMOUNT: int = 2000 const DEV_RESEARCH_POINTS: int = 500 const DEV_UNLIMITED_AMOUNT: int = 999999 # A post-prologue game stocked for play-testing: every resource banked, # research points to spend, and one of each planet type next to the spawn # (WorldGen reads dev_mode for that). Offered by the main menu only when # running from Godot - see MainMenu.gd - so it cannot be reached in a shipped # build. Dev mode is for iterating on the game itself, not replaying the long # scripted introduction before every check. # # Deliberately built ON TOP of reset_new_game() rather than beside it, so a # dev game is a normal game plus a stock rather than a second, separately # maintained definition of what a fresh game is. func start_dev_game() -> void: reset_new_game() dev_mode = true intro_active = false for resource_type in resource_types(): bank_inventory.set_amount(resource_type, DEV_RESOURCE_AMOUNT) research_points = DEV_RESEARCH_POINTS research_points_changed.emit(research_points) # Development toggles deliberately live beside the state they affect. The # menu is only a front-end; scene swaps cannot turn an infinite bank off or # forget which doctrines were exposed. func set_dev_unlimited_resources(enabled: bool) -> bool: if enabled and not dev_mode: return false dev_unlimited_resources = enabled if enabled: _refill_dev_resources() return true func set_dev_free_costs(enabled: bool) -> bool: if enabled and not dev_mode: return false dev_free_costs = enabled return true # Asked by every price below, and by the menus that display them. The dev_mode # term is not redundant: the setter refuses outside a dev game and the dev_mode # setter clears the flag, so this is the third guard rather than the first, and # it is the one that holds if a save or a test sets the field directly. func costs_are_free() -> bool: return dev_mode and dev_free_costs func _refill_dev_resources() -> void: for resource_type in resource_types(): if bank_inventory.amount(resource_type) < DEV_UNLIMITED_AMOUNT: bank_inventory.set_amount(resource_type, DEV_UNLIMITED_AMOUNT) if research_points < DEV_UNLIMITED_AMOUNT: research_points = DEV_UNLIMITED_AMOUNT research_points_changed.emit(research_points) func dev_unlock_all_skills() -> int: if not dev_mode: return 0 var added: int = 0 for node in SKILL_TREE: var id: String = String(node.id) if id in unlocked: continue unlocked.append(id) added += 1 return added # --- 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 = 180.0 signal order_completed(building_id: int, type: String) # #99: fired after the record is already gone, so a listener that reacts by # asking GameState about the world sees the world without it. signal building_destroyed(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": [], "hp": building_max_hp(type), }) return id func find_building(id: int) -> Dictionary: for b in buildings: if b.get("id", 0) == id: return b return {} # --- what a structure can take before it comes down (#99) ------------------ # # Health lives on the RECORD, not on the Building node. Main.tscn is torn # down and rebuilt on every trip to a planet surface, so damage kept on a # node would heal itself the moment the player landed - the same reason the # buildings themselves live here. # What one structure costs, in every resource it wants. # # #138: the same shape product_costs() already answers for vessels - the # dictionary when a definition carries one, the ore figure otherwise - so # there is one question with one answer whatever is being bought. func building_costs(type: String) -> Dictionary: var definition: Resource = building_definition(type) if definition == null or costs_are_free(): return {} if not definition.build_resource_costs.is_empty(): return definition.build_resource_costs.duplicate() return {RESOURCE_ORE: int(definition.cost)} if int(definition.cost) > 0 else {} func building_definition(type: String) -> Resource: return BUILDING_DEFINITIONS.get_definition(type) func building_max_hp(type: String) -> int: var definition: Resource = building_definition(type) return 1 if definition == null else maxi(int(definition.max_hp), 1) func building_hp(id: int) -> int: var b: Dictionary = find_building(id) if b.is_empty(): return 0 # Records written before #99 carry no "hp" at all. Reading that as zero # would present every structure in an existing save as rubble, so a # missing field means undamaged. return int(b.get("hp", building_max_hp(String(b.type)))) func building_health_fraction(id: int) -> float: var b: Dictionary = find_building(id) if b.is_empty(): return 0.0 return clampf(float(building_hp(id)) / float(building_max_hp(String(b.type))), 0.0, 1.0) # The mothership is where the bank, the hangar and research all live, and it # is where a wiped-out fleet is rebuilt. Losing it would delete the player's # savings and their stored vessels with no route back, so it bottoms out at # one hit point instead of dying. Keyed on the capability rather than on the # type: whatever building holds the bank is the one that cannot be lost. # #159: "a building with no life should explode" - and nothing is exempt any # more. The mothership used to floor at 1hp, so a battered one sat there # reading as dead and never came down. # # That exemption was there to stop a soft-lock, and the soft-lock turns out # not to exist. Losing the structure does not lose what is in it: the bank is # GameState.bank_inventory and the hangar is stored_vessels, both of which # outlive any building. And rebuilding is always available - the mothership # is the one structure research never gates, it costs exactly what a single # interceptor's hold carries, and ore comes from asteroids, which do not run # out. So the way back is always mine, fly, rebuild. # # Kept as a function rather than deleted because the QUESTION is still a real # one - something may well need to be un-killable later - and a caller asking # it is clearer than a caller that has forgotten it was ever asked. func building_is_indestructible(_building: Dictionary) -> bool: return false # Returns true when this killed the building. func damage_building(id: int, amount: int) -> bool: if amount <= 0: return false var b: Dictionary = find_building(id) if b.is_empty(): return false var floor_hp: int = 1 if building_is_indestructible(b) else 0 b.hp = maxi(building_hp(id) - amount, floor_hp) if int(b.hp) > 0: return false return destroy_building(id) func repair_building(id: int, amount: int) -> int: if amount <= 0: return 0 var b: Dictionary = find_building(id) if b.is_empty(): return 0 var before: int = building_hp(id) b.hp = mini(before + amount, building_max_hp(String(b.type))) return int(b.hp) - before func destroy_building(id: int) -> bool: var b: Dictionary = find_building(id) if b.is_empty() or building_is_indestructible(b): return false var type: String = String(b.type) var was_hub: bool = building_has_capability(b, "transport_hub") buildings.erase(b) if was_hub: # Its transporters belonged to it and have nowhere to go home to. var kept: Array = [] for transporter in transporters: if int(transporter.get("hub_id", 0)) != id: kept.append(transporter) transporters = kept # Everything else keyed on a building id already copes with the building # being gone: a transporter mid-delivery puts its cargo back rather than # destroying it (see _arrive), and find_building simply returns empty. building_destroyed.emit(id, type) return true # --- outpost garrisons (#103) ---------------------------------------------- # # Two separate things live on an outpost's record, and conflating them was # the first thing to get wrong here: # # garrison what the player ASKED for - {vessel_type: count}. Set in # the outpost's own menu and otherwise untouched. # garrison_crew who is actually THERE - a list of vessel types. #104 # fills it from the mothership; nothing else writes it. # # Keeping the order as a wish rather than as an inventory is what lets an # outpost ask for a vessel nobody has yet and simply wait, instead of the # request evaporating. const GARRISON_BASE_SLOTS: int = 2 # Which vessels can hold a position. #97: "Only ships that attack can be # assigned" - so a shield vessel, a hauler or an engine block cannot, however # much the player might want the shield out there. Written as capabilities # rather than as a list of types, so a new armed vessel is eligible the day # it is added and an unarmed one never is by accident. # # #105 shipped with "weapon" only, because firing the laser and the gas bomb # lived in the player's controllers rather than in the vessels' own # components - a garrisoned laser would have held its position and never # shot. Both effects now live in the world as LaserBeamEffect/GasBombEffect # and both components expose the same fire_ability() seam, so a garrison can # pull either trigger and the list is no longer a apology for a missing # feature. const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser", "gas_bomber"] func outpost_slot_capacity() -> int: var n: int = GARRISON_BASE_SLOTS for id in unlocked: n += int(skill_node(id).get("outpost_bonus", 0)) return n func vessel_can_garrison(vessel_type: String) -> bool: var definition: Resource = vessel_definition(vessel_type) if definition == null or not definition.fleet_eligible: return false for capability in GARRISON_CAPABILITIES: if capability in definition.capabilities: return true return false func garrison_vessel_types() -> PackedStringArray: var out: PackedStringArray = PackedStringArray() for definition in VESSEL_DEFINITIONS.all(): if vessel_can_garrison(String(definition.definition_id)): out.append(String(definition.definition_id)) return out func is_outpost(building: Dictionary) -> bool: return building_has_capability(building, "garrison") # #163: how many vessels are actually standing at outposts, across all of # them. Counts the CREW rather than what was asked for - a vessel still in # transit has not started holding anything yet. func garrisoned_vessel_count() -> int: var total: int = 0 for building in buildings: if is_outpost(building): total += garrison_crew(int(building.id)).size() return total func garrison_desired(id: int) -> Dictionary: var b: Dictionary = find_building(id) if b.is_empty() or not is_outpost(b): return {} if not b.has("garrison"): b.garrison = {} return b.garrison func garrison_desired_total(id: int) -> int: var n: int = 0 for count in garrison_desired(id).values(): n += int(count) return n func garrison_desired_of(id: int, vessel_type: String) -> int: return int(garrison_desired(id).get(vessel_type, 0)) # Returns the count actually set, so the caller can show what happened when # the request was clamped rather than silently doing something else. func set_garrison_desired(id: int, vessel_type: String, count: int) -> int: var b: Dictionary = find_building(id) if b.is_empty() or not is_outpost(b) or not vessel_can_garrison(vessel_type): return 0 var wanted: Dictionary = garrison_desired(id) var others: int = garrison_desired_total(id) - int(wanted.get(vessel_type, 0)) var room: int = maxi(outpost_slot_capacity() - others, 0) var final_count: int = clampi(count, 0, room) if final_count <= 0: wanted.erase(vessel_type) else: wanted[vessel_type] = final_count return final_count func adjust_garrison_desired(id: int, vessel_type: String, delta: int) -> int: return set_garrison_desired(id, vessel_type, garrison_desired_of(id, vessel_type) + delta) func garrison_crew(id: int) -> Array: var b: Dictionary = find_building(id) if b.is_empty() or not is_outpost(b): return [] if not b.has("garrison_crew"): b.garrison_crew = [] return b.garrison_crew # --- what a garrison keeps beside it --------------------------------------- # # An outpost stores ammunition for the vessels actually stationed there: 50 # gas for each gas bomber, 50 energy for each laser. The numbers come off the # VESSEL definitions, so the outpost never learns which vessel needs what - # add an armed vessel with a magazine and its outpost storage follows. # # This is a CAPACITY, not a stock. Hub transporters fill it the same way they # fill a gas burner's buffer (see building_logistics_deficit), and the # garrison AI spends from it. func garrison_magazine_capacity(id: int, resource_type: String) -> int: var total: int = 0 for vessel_type in garrison_crew(id): var definition: Resource = vessel_definition(String(vessel_type)) if definition == null: continue if String(definition.garrison_ammo_resource) != resource_type: continue total += int(definition.garrison_ammo_capacity) return total # Every resource this outpost wants held, as {resource_type: capacity}. func garrison_magazines(id: int) -> Dictionary: var out: Dictionary = {} for vessel_type in garrison_crew(id): var definition: Resource = vessel_definition(String(vessel_type)) if definition == null: continue var resource_type: String = String(definition.garrison_ammo_resource) if resource_type == "" or int(definition.garrison_ammo_capacity) <= 0: continue out[resource_type] = int(out.get(resource_type, 0)) \ + int(definition.garrison_ammo_capacity) return out # Spends from the outpost's own store. Returns false when it cannot pay in # full - a half-priced shot would be worse than no shot. func spend_garrison_ammo(id: int, resource_type: String, amount: int) -> bool: if amount <= 0: return true var inventory: RefCounted = building_inventory(id) if inventory == null or inventory.amount(resource_type) < amount: return false inventory.withdraw(resource_type, amount) return true func garrison_crew_of(id: int, vessel_type: String) -> int: var n: int = 0 for crewed in garrison_crew(id): if String(crewed) == vessel_type: n += 1 return n # What this outpost is still short of, as {vessel_type: count}. The one # question #104 has to answer every tick, so it is asked here rather than # recomputed by whoever needs it. func garrison_shortfall(id: int) -> Dictionary: var out: Dictionary = {} for vessel_type in garrison_desired(id): # What is already on its way counts. Without this the dispatcher # would send the whole hangar to fill one berth, because the crew # does not grow until the first arrival lands. var missing: int = int(garrison_desired(id)[vessel_type]) \ - garrison_crew_of(id, String(vessel_type)) \ - garrison_inbound_of(id, String(vessel_type)) if missing > 0: out[vessel_type] = missing return out func garrison_inbound_of(id: int, vessel_type: String) -> int: var n: int = 0 for delivery in vessel_deliveries: if int(delivery.get("to_outpost", 0)) == id \ and String(delivery.type) == vessel_type: n += 1 return n func garrison_inbound(id: int) -> int: var n: int = 0 for delivery in vessel_deliveries: if int(delivery.get("to_outpost", 0)) == id: n += 1 return n func outpost_ids() -> Array: var out: Array = [] for b in buildings: if is_outpost(b): out.append(int(b.id)) return out 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 func building_logistics_profile(building: Dictionary) -> Resource: if building.is_empty(): return null var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type)) return null if definition == null else definition.logistics # --- vessel types and their upgrades (#5) --------------------------------- # Combat vessels can join the player fleet. Transporter metadata lives in the # same definition catalog so the lab can upgrade its cargo and travel stats, # but transporters themselves belong to hubs and are never production orders. const VESSEL_INTERCEPTOR: String = "interceptor" const VESSEL_SHIELD: String = "shield" const VESSEL_GAS_BOMBER: String = "gas_bomber" const VESSEL_TRANSPORTER: String = "transporter" const VESSEL_CARRIER: String = "carrier" const VESSEL_LASER: String = "laser" const VESSEL_DISTORDER: String = "distorder" # Compatibility constants for callers and old tests. Runtime metadata is # resolved from VesselDefinition below. const SHIELD_COST: int = 14 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 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_fleet_eligible(vessel: String) -> bool: var definition: Resource = vessel_definition(vessel) return definition != null and definition.fleet_eligible 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, payment_position: Variant = null) -> 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 var costs: Dictionary = research_costs(definition.unlock_id) # Through the accessor, not off the definition: this used to be the one # place a research price was read straight from its file, which #202's # free-costs switch would have left charging full price here alone. var points_needed: int = research_point_cost(definition.unlock_id) if research_points < points_needed or not spend_resources(costs, payment_position): return false if points_needed > 0: research_points -= points_needed research_points_changed.emit(research_points) 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() or costs_are_free(): return 0 return int(d.cost) * (upgrade_level(id) + 1) func can_buy_upgrade(id: String, payment_position: Variant = null) -> bool: return has_ship_lab() and not upgrade_maxed(id) \ and can_afford(upgrade_cost(id), payment_position) \ and research_points >= research_point_cost(id) func buy_upgrade(id: String, payment_position: Variant = null) -> bool: if not can_buy_upgrade(id, payment_position): return false if not spend(upgrade_cost(id), payment_position): return false var points_needed: int = research_point_cost(id) if points_needed > 0: research_points -= points_needed research_points_changed.emit(research_points) 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") # #56: sums cargo_capacity over ships that actually declare the "cargo" # capability, so combat specialists (no capability, whatever their stat # dict says) contribute nothing - data-driven rather than a vessel-id list. # Takes the composition as an argument rather than reading it off a live # Fleet node: GameState has no fixed reference to "the" Fleet (space and a # planet surface each own a separate one), so the caller passes its own # Fleet.vessel_types(). func fleet_cargo_capacity(vessel_types: PackedStringArray) -> int: var total: int = 0 for vessel_type in vessel_types: if vessel_has_capability(vessel_type, "cargo"): total += int(vessel_stat(vessel_type, "cargo_capacity")) return total func sync_fleet_cargo_capacity(vessel_types: PackedStringArray) -> void: fleet_inventory.total_capacity = fleet_cargo_capacity(vessel_types) 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") # #217: what a Burn Drive multiplies that by while there is energy aboard. # 1.0 until the upgrade is researched, which is also the flag for whether a # transporter should go looking for fuel at all - an unupgraded one has no # use for it and must never detour. func transporter_fuel_boost() -> float: var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport") return maxf(vessel_stat(definition.definition_id, "fuel_boost"), 1.0) func transporter_fuel_capacity() -> int: var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport") return int(vessel_stat(definition.definition_id, "fuel_capacity")) func transporter_burns_energy() -> bool: return transporter_fuel_boost() > 1.0 and transporter_fuel_capacity() > 0 # The speed THIS transporter is making: the base crawl, multiplied while it # still has something in the tank. func transporter_leg_speed(t: Dictionary) -> float: if int(t.get("fuel", 0)) > 0: return transporter_speed() * transporter_fuel_boost() return transporter_speed() const PRODUCT_SHIP: String = "interceptor" func product_cost(product: String) -> int: return int(product_costs(product).get(RESOURCE_ORE, 0)) func product_costs(product: String) -> Dictionary: var definition: Resource = vessel_definition(product) if definition == null or costs_are_free(): return {} if not definition.build_resource_costs.is_empty(): return definition.build_resource_costs.duplicate() return {RESOURCE_ORE: definition.build_cost} if definition.build_cost > 0 else {} 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): var inventory: RefCounted = _inventory_for_record(b) var costs: Dictionary = product_costs(String(order.product)) for resource_type in costs: inventory.deposit(String(resource_type), int(costs[resource_type])) orders.remove_at(index) return true func outstanding_cost(id: int) -> int: return outstanding_resource_cost(id, RESOURCE_ORE) func outstanding_costs(id: int) -> Dictionary: var result: Dictionary = {} for resource_type in resource_types(): var need: int = outstanding_resource_cost(id, resource_type) if need > 0: result[resource_type] = need return result func outstanding_resource_cost(id: int, resource_type: String) -> 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", []): if not bool(o.started): need += int(product_costs(String(o.product)).get(resource_type, 0)) for order in b.get("research_orders", []): if not bool(order.started): need += int(research_costs(String(order.id)).get(resource_type, 0)) return maxi(need - _inventory_for_record(b).amount(resource_type), 0) func deposit_to_building(id: int, amount: int) -> int: return deposit_resource_to_building(id, RESOURCE_ORE, amount) func deposit_resource_to_building(id: int, resource_type: String, 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_type, 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) # #216: support craft are orderable here too. They never join the fleet, # so "can the crafter make it" is its own question now. if definition == null or not vessel_is_unlocked(product) \ or not (definition.fleet_eligible or definition.support_craftable): return false (b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false}) return true # What a building can actually pay a local cost with (#158). # # #12 made production cost resources physically DELIVERED to the building # rather than money out of the bank, and that stands: the bank is still not # raided from here. What #158 adds is the fleet's own hold - the one other # place resources physically are. A hull that flew in carrying gas can hand # it over, and making the player park, open the transfer menu and move it # across by hand was ceremony rather than a decision. # # But only WHERE THE FLEET IS, and that qualifier is the whole design. # Without it a crafter on the far side of the map reaches into the hold and # spends ore the player withdrew to carry somewhere else - which is exactly # the complaint that got automatic banking at the mothership removed in #24, # arriving from the other direction. test_manual_supply_route caught it # doing precisely that. # # The range is Building.interact_radius, the same distance that decides # whether the transfer button is offered at all. So the rule is not a hidden # one: if you could hand the cargo over by hand, the building can take it. const LOCAL_PAY_RANGE: float = 150.0 func fleet_is_at(building: Dictionary) -> bool: if building.is_empty(): return false return fleet_can_pay_at(building.position) func fleet_can_pay_at(payment_position: Variant) -> bool: return payment_position is Vector2 \ and fleet_position.distance_to(payment_position as Vector2) <= LOCAL_PAY_RANGE func local_payment_available(building: Dictionary, inventory: RefCounted, resource_type: String) -> int: var here: int = 0 if inventory == null else inventory.amount(resource_type) return here + (fleet_inventory.amount(resource_type) if fleet_is_at(building) else 0) func can_pay_locally(building: Dictionary, inventory: RefCounted, costs: Dictionary) -> bool: for resource_type in costs: if local_payment_available(building, inventory, String(resource_type)) \ < int(costs[resource_type]): return false return true # All or nothing, settled in full before anything is taken - a part-charged # order that then fails is worse than one that never started. The building's # own stock goes first, so what was hauled in is spent before the hold the # player is still flying around with. func pay_locally(building: Dictionary, inventory: RefCounted, costs: Dictionary) -> bool: if not can_pay_locally(building, inventory, costs): return false var at_hand: bool = fleet_is_at(building) for resource_type in costs: var id: String = String(resource_type) var owed: int = int(costs[resource_type]) if inventory != null: owed -= inventory.withdraw(id, owed) if owed > 0 and at_hand: fleet_inventory.withdraw(id, owed) 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 costs: Dictionary = product_costs(product) var inventory: RefCounted = _inventory_for_record(b) var definition: Resource = vessel_definition(product) if not bool(order.started): # #158: the crafter's own stock, or the hold that flew in. if not pay_locally(b, inventory, costs): continue order.started = true order.elapsed = float(order.elapsed) + delta if float(order.elapsed) < build_time(product): continue orders.pop_front() # A finished hull launches from the crafter and physically flies to the # mothership hangar - support craft included since #216, which is what # _advance_station_dispatch() then draws on to staff a hub or a # workshop. 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) # --- crewing an outpost from the mothership (#104) -------------------------- # # "the game should transfer, whenever available, ships from the mothership to # the outpost to always fullfill the outpost conditions if possible." # # Vessels come from the HANGAR - stored_vessels - and never from the fleet. # The fleet is what the player is flying; taking a vessel out of it to crew a # building would be the game undoing the player's own arrangement. "Whenever # available" therefore means "when one is sitting at the mothership", and an # outpost asking for a hull nobody has built simply waits. # # They travel rather than teleporting, down the same vessel_deliveries path a # finished crafter order already uses to reach the mothership. func _garrison_still_wants(id: int, vessel_type: String) -> bool: var b: Dictionary = find_building(id) if b.is_empty() or not is_outpost(b): return false return garrison_crew_of(id, vessel_type) < garrison_desired_of(id, vessel_type) func _take_stored_vessel(vessel_type: String) -> bool: var index: int = stored_vessels.find(vessel_type) if index < 0: return false stored_vessels.remove_at(index) return true func _advance_garrison_dispatch() -> void: var mothership: Variant = get_mothership_position() if mothership == null: return for b in buildings: if not is_outpost(b): continue var id: int = int(b.id) # Over strength first: an order the player has just lowered should # release the vessel back to the hangar rather than stranding it. _return_surplus_garrison(b) for vessel_type in garrison_shortfall(id): var needed: int = int(garrison_shortfall(id)[vessel_type]) for i in range(needed): if not _take_stored_vessel(String(vessel_type)): break vessel_deliveries.append({ "type": String(vessel_type), "from": mothership, "to": b.position, "progress": 0.0, "to_outpost": id, }) func _return_surplus_garrison(b: Dictionary) -> void: var id: int = int(b.id) var mothership: Variant = get_mothership_position() if mothership == null: return var crew: Array = garrison_crew(id) for i in range(crew.size() - 1, -1, -1): var vessel_type: String = String(crew[i]) if garrison_crew_of(id, vessel_type) <= garrison_desired_of(id, vessel_type): continue crew.remove_at(i) vessel_deliveries.append({ "type": vessel_type, "from": b.position, "to": mothership, "progress": 0.0, "to_outpost": 0, }) # --- standing orders, and the census behind them (#106) -------------------- # # "how many ships of a give type should always be available over the whole # game (fleet + storage + outposts)". # # The targets are GLOBAL, not per building. There is one plan for the whole # game; a second Planning Center is a second place to read and edit the same # plan, not a competing one. Storing them per building would mean two # centres could hold contradictory orders and the crafters would be told to # satisfy both. # # The census is the harder half, and the rule is that every vessel is # counted exactly once, wherever it happens to be at that instant - flying # in the fleet, parked in the hangar, stationed at an outpost, or in the air # between any two of those. Miss the in-flight ones and the planner queues a # replacement for a vessel that is merely en route. var vessel_targets: Dictionary = {} func has_planning_center() -> bool: for b in buildings: if building_has_capability(b, "planning"): return true return false func vessel_target(vessel_type: String) -> int: return int(vessel_targets.get(vessel_type, 0)) func set_vessel_target(vessel_type: String, count: int) -> int: if not vessel_is_fleet_eligible(vessel_type): return 0 var final_count: int = maxi(count, 0) if final_count == 0: vessel_targets.erase(vessel_type) else: vessel_targets[vessel_type] = final_count return final_count func adjust_vessel_target(vessel_type: String, delta: int) -> int: return set_vessel_target(vessel_type, vessel_target(vessel_type) + delta) func planned_vessel_types() -> PackedStringArray: var out: PackedStringArray = PackedStringArray() for definition in VESSEL_DEFINITIONS.all(): if bool(definition.fleet_eligible): out.append(String(definition.definition_id)) return out # How many of this type exist anywhere. Broken out per place rather than # summed inline so the census can be read - and tested - one location at a # time. func vessel_census(vessel_type: String) -> int: return vessel_count_in_fleet(vessel_type) \ + vessel_count_in_hangar(vessel_type) \ + vessel_count_in_garrisons(vessel_type) \ + vessel_count_in_transit(vessel_type) func vessel_count_in_fleet(vessel_type: String) -> int: var n: int = 0 for flying in fleet_vessel_types: if String(flying) == vessel_type: n += 1 return n func vessel_count_in_hangar(vessel_type: String) -> int: var n: int = 0 for stored in stored_vessels: if String(stored) == vessel_type: n += 1 return n func vessel_count_in_garrisons(vessel_type: String) -> int: var n: int = 0 for id in outpost_ids(): n += garrison_crew_of(id, vessel_type) return n # Both directions of flight: a finished hull on its way to the mothership, # and a vessel being moved to or from an outpost. All of them are vessels # that exist and will land somewhere. func vessel_count_in_transit(vessel_type: String) -> int: var n: int = 0 for delivery in vessel_deliveries: if String(delivery.type) == vessel_type: n += 1 return n # Ordered but not yet built. Kept separate from the census on purpose: these # do not exist yet, so a readout has to be able to say "4 of 6, 2 building" # rather than claiming six. func vessel_count_on_order(vessel_type: String) -> int: var n: int = 0 for b in buildings: if not can_produce(String(b.type)): continue for order in b.get("orders", []): if String(order.product) == vessel_type: n += 1 return n func vessel_shortfall(vessel_type: String) -> int: return maxi(vessel_target(vessel_type) - vessel_census(vessel_type) - vessel_count_on_order(vessel_type), 0) # --- the planner working the crafters (#107) ------------------------------- # # "the planning center automatically manage the ship craft building queues to # ensure that the needed vessels are produced to fullfill the constraints." # # Three rules keep this from fighting the player: # # It TOPS UP, never rewrites. Orders placed by hand are left exactly where # they are; the planner only ever appends, and only for a type that is # genuinely short. Cancelling one of the player's orders to make room for # one of its own would be the game arguing with them. # # Everything counts. A vessel already queued, already flying to the # mothership, or already stationed at an outpost is a vessel that exists or # shortly will - see vessel_shortfall(). Without that it re-queues the same # shortfall every tick and buries the player in hulls. # # Nothing happens without a planning centre. Destroy it and the standing # orders stop being acted on, though they are remembered, so rebuilding # picks up where it left off rather than starting from a blank plan. func _crafter_for_planning() -> Dictionary: # The shortest queue, so orders spread across the crafters that exist # rather than piling onto whichever happens to be first in the list. var best: Dictionary = {} var best_queue: int = 0 for b in buildings: if not can_produce(String(b.type)): continue var queued: int = (b.get("orders", []) as Array).size() if best.is_empty() or queued < best_queue: best = b best_queue = queued return best func _advance_planning() -> void: if vessel_targets.is_empty() or not has_planning_center(): return for vessel_type in vessel_targets: var type_id: String = String(vessel_type) if not vessel_is_unlocked(type_id): continue var missing: int = vessel_shortfall(type_id) if missing <= 0: continue var crafter: Dictionary = _crafter_for_planning() if crafter.is_empty(): return # ONE per pass, not the whole shortfall. The queue is spread across # crafters by _crafter_for_planning(), and asking for the whole # shortfall at once would put it all on the shortest queue before # that had a chance to change. queue_order(int(crafter.id), type_id) # --- the spacial workshop and its repair drones (#116) --------------------- # # Modelled on the transport hub, deliberately and closely: drones belong to a # workshop rather than to the player, are provisioned when it is built, are # never a production order, and can be upgraded at a ship lab like any other # vessel. Anything that already knows how hub transporters behave knows how # these do. # # What a repair COSTS is the one new idea: a share of the building's own # price, in proportion to how much of its health is gone. Levelling a crafter # and rebuilding it costs its full price; patching one up after a wave costs # a fraction, and the fraction is the damage. const BASE_WORKSHOP_DRONES: int = 2 const REPAIR_DRONE: String = "repair_drone" # What a drone puts back per trip, before upgrades. const BASE_REPAIR_AMOUNT: int = 25 const DRONE_IDLE: String = "idle" const DRONE_OUTBOUND: String = "outbound" const DRONE_RETURNING: String = "returning" var repair_drones: Array = [] func workshop_drone_capacity() -> int: var n: int = BASE_WORKSHOP_DRONES for id in unlocked: n += int(skill_node(id).get("workshop_bonus", 0)) return n func has_workshop() -> bool: for b in buildings: if building_has_capability(b, "repair"): return true return false func workshop_ids() -> Array: var out: Array = [] for b in buildings: if building_has_capability(b, "repair"): out.append(int(b.id)) return out # The ore a full repair of this building would take: its own price, scaled by # the share of its health that is missing. A structure at full health costs # nothing, one levelled to a sliver costs nearly what it did to build. func repair_cost(id: int) -> int: var b: Dictionary = find_building(id) if b.is_empty(): return 0 var definition: Resource = building_definition(String(b.type)) if definition == null: return 0 var missing: float = 1.0 - building_health_fraction(id) return int(ceil(float(definition.cost) * missing)) func repair_amount() -> int: return int(vessel_stat(REPAIR_DRONE, "repair_amount")) # What one drone-load of repair costs, for a building that needs one. func repair_trip_cost(id: int) -> int: var b: Dictionary = find_building(id) if b.is_empty(): return 0 var definition: Resource = building_definition(String(b.type)) if definition == null: return 0 var per_point: float = float(definition.cost) / float(maxi(building_max_hp(String(b.type)), 1)) var putting_back: int = mini(repair_amount(), building_max_hp(String(b.type)) - building_hp(id)) return maxi(int(ceil(per_point * float(putting_back))), 1) if putting_back > 0 else 0 func _damaged_buildings() -> Array: var out: Array = [] for b in buildings: if building_health_fraction(int(b.id)) < 0.999: out.append(b) return out # #116: "Required resources are sent to the spacial workshop". The workshop # asks hub logistics for the ore its outstanding repairs will need, exactly # as a gas burner asks for gas - see building_logistics_deficit. func workshop_ore_demand(id: int) -> int: var b: Dictionary = find_building(id) if b.is_empty() or not building_has_capability(b, "repair"): return 0 var needed: int = 0 for damaged in _damaged_buildings(): needed += repair_cost(int(damaged.id)) return mini(needed, WORKSHOP_ORE_BUFFER) # A ceiling, so one badly-mauled base does not pull the entire bank into a # single workshop and starve everything else. const WORKSHOP_ORE_BUFFER: int = 120 func _add_workshop_drone(workshop: Dictionary) -> void: repair_drones.append({ "workshop_id": int(workshop.id), "state": DRONE_IDLE, "target_id": 0, "from": workshop.position, "to": workshop.position, "progress": 1.0, "carrying": 0, "repairs_left": DRONE_REPAIRS, }) func repair_drone_position(drone: Dictionary) -> Vector2: return (drone.from as Vector2).lerp(drone.to as Vector2, clampf(float(drone.progress), 0.0, 1.0)) func _drone_inbound_to(building_id: int) -> bool: for drone in repair_drones: if String(drone.state) == DRONE_OUTBOUND and int(drone.target_id) == building_id: return true return false func _advance_repairs(delta: float) -> void: if repair_drones.is_empty(): return var speed: float = vessel_stat(REPAIR_DRONE, "transport_speed") # Duplicated because a drone that runs out of work is removed from the # list below, and #216 made that reachable from inside this loop. for drone in repair_drones.duplicate(): var workshop: Dictionary = find_building(int(drone.workshop_id)) if workshop.is_empty(): continue if String(drone.state) == DRONE_IDLE: _dispatch_drone(drone, workshop) continue var distance: float = (drone.from as Vector2).distance_to(drone.to as Vector2) drone.progress = float(drone.progress) + delta / maxf(distance / maxf(speed, 1.0), 0.001) if float(drone.progress) < 1.0: continue drone.progress = 1.0 if String(drone.state) == DRONE_OUTBOUND: # The ore was taken when it left, so a building demolished in # transit does not silently consume it - it goes home with the # load and the deposit below puts it back. var target: Dictionary = find_building(int(drone.target_id)) if not target.is_empty(): repair_building(int(drone.target_id), repair_amount()) else: deposit_resource_to_building(int(drone.workshop_id), RESOURCE_ORE, int(drone.carrying)) drone.carrying = 0 drone.target_id = 0 _send_drone(drone, workshop.position, DRONE_RETURNING) else: drone.state = DRONE_IDLE # #216: counted where the run ENDS, so a drone is never retired # with a building half repaired behind it. drone.repairs_left = int(drone.get("repairs_left", DRONE_REPAIRS)) - 1 if int(drone.repairs_left) <= 0: _retire_drone(drone) func _dispatch_drone(drone: Dictionary, workshop: Dictionary) -> void: var inventory: RefCounted = building_inventory(int(workshop.id)) if inventory == null: return var best: Dictionary = {} var best_health: float = 1.0 for damaged in _damaged_buildings(): var id: int = int(damaged.id) # One drone per building at a time: two arriving at the same wall # would spend twice the ore to put back one drone-load of health. if _drone_inbound_to(id): continue var cost: int = repair_trip_cost(id) if cost <= 0 or inventory.amount(RESOURCE_ORE) < cost: continue var health: float = building_health_fraction(id) if best.is_empty() or health < best_health: best = damaged best_health = health if best.is_empty(): return # Worst first: the structure closest to coming down is the one a repair # is worth most on. var spend: int = repair_trip_cost(int(best.id)) inventory.withdraw(RESOURCE_ORE, spend) drone.carrying = spend drone.target_id = int(best.id) drone.from = workshop.position _send_drone(drone, best.position, DRONE_OUTBOUND) func _send_drone(drone: Dictionary, to: Vector2, state: String) -> void: drone.from = repair_drone_position(drone) drone.to = to drone.progress = 0.0 drone.state = state 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 research_cost(id: String) -> int: return int(research_costs(id).get(RESOURCE_ORE, 0)) func research_point_cost(id: String) -> int: if costs_are_free(): return 0 var unlock_definition: Resource = vessel_for_unlock(id) if unlock_definition != null: return int(unlock_definition.research_point_cost) var d: Dictionary = upgrade_def(id) return int(d.resource.research_point_cost) if not d.is_empty() else 0 func research_costs(id: String) -> Dictionary: if costs_are_free(): return {} var unlock_definition: Resource = vessel_for_unlock(id) if unlock_definition != null: if not unlock_definition.unlock_resource_costs.is_empty(): return unlock_definition.unlock_resource_costs.duplicate() return {RESOURCE_ORE: unlock_definition.unlock_cost} if unlock_definition.unlock_cost > 0 else {} var cost: int = upgrade_cost(id) return {RESOURCE_ORE: cost} if cost > 0 else {} 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 costs: Dictionary = research_costs(id) var inventory: RefCounted = _inventory_for_record(lab) if not bool(order.started): # #158: the lab's own stock, or the hold that flew in. var affordable: bool = can_pay_locally(lab, inventory, costs) # #63: research points are a fleet-wide currency, not delivered # to the lab like ore/gas - checked and spent here too so a # shortfall stalls the order the same way an undelivered # resource does, rather than letting it start regardless. var points_needed: int = research_point_cost(id) if points_needed > research_points: affordable = false if not affordable: continue if not pay_locally(lab, inventory, costs): continue if points_needed > 0: research_points -= points_needed research_points_changed.emit(research_points) 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 # Whether a structure of this type exists ANYWHERE, however it is stored. # # #145: extractors are not in `buildings`. They are placed onto a planet from # space and live in `extractors`, with their own position, their own stock # and their own id - so has_building_of_type("extractor") is false no matter # how many the player has built. The quest that asks for one (#133) was # asking a question that could never be answered yes, and no amount of # building extractors was going to finish it. # # Anything asking "does the player have one of these" wants THIS, and # has_building_of_type() only where the answer really is about the # `buildings` list itself. func structure_exists(type: String) -> bool: if type == BUILDING_EXTRACTOR: return not extractors.is_empty() return has_building_of_type(type) func remove_buildings_of_type(type: String) -> int: var kept: Array = [] var removed_hub_ids: Array = [] var removed: int = 0 for b in buildings: if b.type == type: removed += 1 if building_has_capability(b, "transport_hub"): removed_hub_ids.append(int(b.id)) else: kept.append(b) buildings = kept if not removed_hub_ids.is_empty(): var kept_transporters: Array = [] for transporter in transporters: if int(transporter.get("hub_id", 0)) not in removed_hub_ids: kept_transporters.append(transporter) transporters = kept_transporters 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. # Twenty slots, because max_fleet_size() caps itself on this array's length - # so the wedge has to be long enough for every doctrine to be worth buying. # Each further pair continues the same V rather than starting a new shape: # the rows step 30 out and 40 back, which is the rule the first ten already # followed, so the wedge that arrives at twenty is the one that left at three. 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), Vector2(-120, 112), Vector2(120, 112), Vector2(-150, 152), Vector2(150, 152), Vector2(-180, 192), Vector2(180, 192), Vector2(-210, 232), Vector2(210, 232), Vector2(-240, 272), Vector2(240, 272), Vector2(-270, 312), Vector2(270, 312), ] 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"}. # Ore per second, per extractor. # # Halved once alongside the planet-density change in WorldGen (roughly 2.5x as # many planets to build on, so holding the rate would have multiplied passive # income rather than moving where it comes from), and halved again because it # still outran the rest of the economy. At 0.5 an extractor pays back its own # 25-ore cost in fifty seconds and yields 30 a minute after that, so a # single lucky early site cannot carry a game and expanding is what grows one. const EXTRACTOR_RATE: float = 0.5 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 func extractor_index_at(planet_key: String) -> int: for i in range(extractors.size()): var extractor: Dictionary = extractors[i] if String(extractor.get("planet_id", extractor.get("planet_name", ""))) == planet_key: return i return -1 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_resource(index: int, resource_type: String) -> int: if index < 0 or index >= extractors.size(): return 0 return _extractor_inventory(extractors[index]).amount(resource_type) func transfer_resource_to_extractor(index: int, resource_type: String, amount: int) -> int: if index < 0 or index >= extractors.size(): return 0 return fleet_inventory.transfer_to(_extractor_inventory(extractors[index]), resource_type, amount) func transfer_resource_from_extractor(index: int, resource_type: String, amount: int) -> int: if index < 0 or index >= extractors.size(): return 0 return _extractor_inventory(extractors[index]).transfer_to(fleet_inventory, resource_type, amount) 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 # --- building logistics and resource conversion --------------------------- func building_logistics_deficit(id: int, resource_type: String) -> int: var building: Dictionary = find_building(id) var target: int = 0 var profile: Resource = building_logistics_profile(building) if profile != null: target = int(profile.input_buffers.get(resource_type, 0)) # An outpost has no logistics profile at all - what it wants held depends # on who is stationed there, which is not something a definition can # state. Taking the larger of the two keeps the static case untouched. target = maxi(target, garrison_magazine_capacity(id, resource_type)) # #116: and a workshop asks for the ore its outstanding repairs will # take, which likewise depends on the world rather than on a definition. if resource_type == RESOURCE_ORE: target = maxi(target, workshop_ore_demand(id)) return maxi(0, target - building_resource(id, resource_type)) func building_requested_resource(id: int, resource_type: String) -> int: return maxi(outstanding_resource_cost(id, resource_type), building_logistics_deficit(id, resource_type)) func _collectable_building_resource(building: Dictionary, minimum_amount: int) -> String: var profile: Resource = building_logistics_profile(building) if profile == null: return "" for resource_type in profile.collected_outputs: if building_resource(int(building.id), resource_type) >= minimum_amount: return resource_type return "" # #78: a converter can be switched off. A gas burner turns gas the player may # want for bombs into energy they may not need yet, and until now it did that # relentlessly - the only way to stop it was to demolish it. Paused is stored # on the building record like every other piece of its state. func is_building_paused(id: int) -> bool: return bool(find_building(id).get("paused", false)) func set_building_paused(id: int, paused: bool) -> void: var building: Dictionary = find_building(id) if building.is_empty(): return building.paused = paused building_paused_changed.emit(id, paused) func toggle_building_paused(id: int) -> bool: var paused: bool = not is_building_paused(id) set_building_paused(id, paused) return paused signal building_paused_changed(id: int, paused: bool) func _advance_resource_converters(delta: float) -> void: for building in buildings: var profile: Resource = building_logistics_profile(building) if profile == null or profile.conversion_input == "" \ or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \ or profile.conversion_yield <= 0: continue if bool(building.get("paused", false)): # Progress is left exactly where it was rather than reset, so # switching a burner off and on again does not throw away the # fraction of a unit it had already worked through. continue var inventory: RefCounted = _inventory_for_record(building) if inventory.amount(profile.conversion_input) <= 0: continue var progress: float = float(building.get("conversion_progress", 0.0)) \ + delta * profile.conversion_rate var whole: int = mini(int(floor(progress)), inventory.amount(profile.conversion_input)) if whole > 0: inventory.withdraw(profile.conversion_input, whole) inventory.deposit(profile.conversion_output, whole * profile.conversion_yield) progress -= whole building.conversion_progress = progress func _distorder_boost_in_use() -> bool: for fleet in get_tree().get_nodes_in_group("player_fleet"): if fleet.has_method("is_using_speed_boost") and fleet.is_using_speed_boost(): return true return false func _advance_distorder_drain(delta: float, boost_in_use: bool = true) -> void: if fleet_speed_multiplier <= 1.0: # Nothing owed at 1.0x - and no stale fractional progress should # carry over into a LATER, unrelated boost. _distorder_drain_progress = 0.0 return if not boost_in_use: # The fader is a selected cruise speed, not an always-running machine. # Keep any already-earned fraction so stopping cannot erase a nearly # complete unit of cost, but accrue nothing while the fleet is parked. return # Normalized against the CURRENT ceiling, so "full boost" always costs # ENERGY_DRAIN_PER_SECOND however many distorder vessels widened it - # a bigger fleet buys more speed, not cheaper speed. # #205: the per-second figure is the Propulser's own stat now, so a Flux # Regulator makes sustained boost cheaper. ENERGY_DRAIN_PER_SECOND remains # what the definition declares, and what this falls back to. var rate: float = (fleet_speed_multiplier - 1.0) / maxf(max_fleet_speed_multiplier - 1.0, 0.001) \ * vessel_stat(VESSEL_DISTORDER, "boost_drain") _distorder_drain_progress += rate * delta var whole: int = int(floor(_distorder_drain_progress)) if whole <= 0: return _distorder_drain_progress -= whole var available: int = fleet_inventory.amount(RESOURCE_ENERGY) if available >= whole: fleet_inventory.withdraw(RESOURCE_ENERGY, whole) return # Ran out mid-drain: take whatever is left (down to exactly zero) and # force the boost back to its default, rather than let the fleet keep # travelling fast on credit. fleet_inventory.withdraw(RESOURCE_ENERGY, available) fleet_speed_multiplier = 1.0 _distorder_drain_progress = 0.0 boost_depleted_notice = true # Planet conquest: a planet is held once its garrison is dead, and only then # can an extractor be built there (see BuildController's placement gates). # Keyed by the same stable planet_id as `extractors`, entirely separate from # it - a planet can be conquered long before anyone builds an extractor on # it, or never get one at all. # # #211 replaced capture points - stand in a ring, wait out a timer, once per # point - with the fight itself. Taking a planet is now exactly "kill what is # defending it", which is what players were doing anyway before walking to # the rings. var planet_capture: Dictionary = {} # #212: carries the research points the conquest actually paid, so whatever # announces it does not have to reconstruct the award from the definition and # the setup's override and get a different answer. signal planet_conquered(planet_id: String, research_points_awarded: int) # Declares what is defending a planet. Called every time the surface is # composed, because the garrison is re-placed every time the player lands: # the count has to start again with the enemies it counts, or a planet left # half-cleared would fall to a single kill on the next visit. # # A planet already conquered is left alone entirely - its garrison is dead # and stays dead. func register_planet_garrison(planet_key: String, enemy_count: int, definition_id: String = "", reward_override: int = 0) -> void: var existing: Dictionary = planet_capture.get(planet_key, {}) if bool(existing.get("conquered", false)): return # A planet defending itself with nothing is vacuously already held (#55: a # black hole's "void" profile has no authored PlanetSetup at all, so this # is the only registration it ever gets). The planet definition supplies an # optional conquest reward, avoiding planet-id special cases here. var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) var point_reward: int = 0 if definition == null else int(definition.conquest_research_points) # A map may be worth more than its planet type's flat rate - a harder # lab should pay better, and only the authored layout knows how hard it # is. Zero means the setup did not express an opinion. if reward_override > 0: point_reward = reward_override planet_capture[planet_key] = { "remaining": enemy_count, "total": enemy_count, "conquered": enemy_count <= 0, # Carried across landings: the reward is paid once, and a planet # re-registered on a second visit must not become payable again. "research_point_reward": point_reward, "research_awarded": bool(existing.get("research_awarded", false)), } func capture_state(planet_key: String) -> Dictionary: return planet_capture.get(planet_key, {"remaining": 0, "total": 0, "conquered": false}) # How much of the garrison is still standing, and how big it was. Both are # for the surface readout; conquest itself is decided below. func garrison_remaining(planet_key: String) -> int: return int(capture_state(planet_key).get("remaining", 0)) func garrison_total(planet_key: String) -> int: return int(capture_state(planet_key).get("total", 0)) # One defender down. Called from the enemy's own destroyed signal, so it # counts kills by whatever means - a beam, a bomb, a ramming hull. func mark_garrison_enemy_destroyed(planet_key: String) -> void: if not planet_capture.has(planet_key): return var state: Dictionary = planet_capture[planet_key] if bool(state.conquered): return state.remaining = maxi(int(state.get("remaining", 0)) - 1, 0) if int(state.remaining) <= 0: state.conquered = true # #63: definition-authored research points, exactly once per planet. # state.conquered flipping above already makes a second call to this # whole function a no-op via the early return above it, but # research_awarded is its own explicit guard on the award itself # regardless of how conquest was reached. var reward: int = int(state.get("research_point_reward", 0)) var awarded: int = 0 if reward > 0 and not bool(state.get("research_awarded", false)): state.research_awarded = true awarded = reward research_points += reward research_points_changed.emit(research_points) # Emitted AFTER the award, so a listener that reports the reward is # reporting one that has already been paid (#212). planet_conquered.emit(planet_key, awarded) func is_planet_conquered(planet_key: String) -> bool: return bool(capture_state(planet_key).get("conquered", false)) # --- waves (#100) ---------------------------------------------------------- # # #97 replaces the roaming spawner - one enemy every few seconds, forever - # with waves: a long quiet, a warning, then an attack. The CLOCK lives here # rather than in the space scene for the usual reason, that Main.tscn is # destroyed on every landing; a wave must not be cancelled, nor restarted # from zero, by a trip to a planet. # # What is deliberately NOT here is the spawning. GameState cannot see the # world, so EnemySpawner reads the phase and puts the enemies in it. That # also settles what happens if the wave turns while the player is on a # surface: the phase goes to ACTIVE with nothing to spawn it, and the wave # is waiting in space when they come back. const WAVE_PHASE_PEACE: String = "peace" const WAVE_PHASE_WARNING: String = "warning" const WAVE_PHASE_ACTIVE: String = "active" const WAVE_PEACE_SECONDS: float = 50.0 # How long the alert is up before anything arrives. Long enough to fly home # and take a position, short enough that it is a warning and not a chore. const WAVE_WARNING_SECONDS: float = 9.0 # A wave that wandered off and never died must not stall the next one for # ever. The director normally ends the phase by clearing the wave. const WAVE_MAX_SECONDS: float = 240.0 var wave_number: int = 0 var wave_phase: String = WAVE_PHASE_PEACE var wave_timer: float = 0.0 # Set when a wave turns ACTIVE, cleared by whoever puts it in the world. # Without it a director arriving mid-wave (the player just came back from a # planet) could not tell "this wave still owes you enemies" from "this wave # is already out there and half dead". var wave_pending: bool = false func reset_waves() -> void: wave_number = 0 wave_phase = WAVE_PHASE_PEACE wave_timer = 0.0 wave_pending = false func wave_is_incoming() -> bool: return wave_phase == WAVE_PHASE_WARNING # Deliberately NOT a remaining-seconds accessor. #97: "No timer should be # visible" - so the one number that could be rendered as a countdown is not # offered to the UI at all, and the alert cannot drift into being a clock. func wave_warning_pulse() -> float: if wave_phase != WAVE_PHASE_WARNING: return 0.0 return 0.5 + 0.5 * sin(wave_timer * 7.0) # Strength is a function of the FLEET, per #97 - a bigger fleet is a bigger # target, so growing it raises the stakes rather than trivialising them. # The wave number is a slower second term, so a player who never grows still # meets escalation rather than the same two chasers all game. func wave_strength(fleet_size: int) -> int: # The flat term matters: without it the opening wave against a one-vessel # fleet is a single chaser, which reads as the system not working rather # than as an easy first wave. return 3 + maxi(fleet_size, 1) * 3 + wave_number * 2 # Spent most-expensive-first, so a large budget buys heavier enemies rather # than an ever-larger swarm of chasers. # # Two gates, not one. min_strength was meant to keep the openings honest, but # strength counts the wave NUMBER as well, so a player who had grown nothing # still met lancers and then snipers simply by surviving - #218's "the first # waves are too complex". # # min_fleet is the gate that actually holds: an enemy type stays out of the # roster until the fleet is big enough to have earned it. At BASE_FLEET_SIZE # the player meets chasers and nothing else, however long they take, and each # vessel past the starting three opens exactly one new kind of trouble. const WAVE_ROSTER: Array = [ {"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6}, {"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5}, {"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4}, {"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0}, ] const WAVE_MAX_ENEMIES: int = 18 func wave_composition(fleet_size: int) -> PackedStringArray: var budget: int = wave_strength(fleet_size) var strength: int = budget var out: PackedStringArray = PackedStringArray() for entry in WAVE_ROSTER: if strength < int(entry.min_strength) \ or fleet_size < int(entry.get("min_fleet", 0)): continue var cost: int = int(entry.cost) # The cheapest tier is last and takes whatever is left, so the budget # is always spent rather than rounded away. var share: int = budget if String(entry.id) == "chaser" else budget / 2 while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES: out.append(String(entry.id)) budget -= cost share -= cost if out.is_empty(): out.append("chaser") return out func advance_waves(delta: float) -> void: wave_timer += delta match wave_phase: WAVE_PHASE_PEACE: if wave_timer >= WAVE_PEACE_SECONDS: wave_phase = WAVE_PHASE_WARNING wave_timer = 0.0 WAVE_PHASE_WARNING: if wave_timer >= WAVE_WARNING_SECONDS: wave_phase = WAVE_PHASE_ACTIVE wave_timer = 0.0 wave_number += 1 wave_pending = true WAVE_PHASE_ACTIVE: if wave_timer >= WAVE_MAX_SECONDS: wave_cleared() # #110: put a wave on the board now, for testing. Skips the peace AND the # warning: the point of the button is to see the wave, and waiting out a # nine-second alert every time would make it useless as a tool. func force_wave_now() -> void: wave_phase = WAVE_PHASE_ACTIVE wave_timer = 0.0 wave_number += 1 wave_pending = true func wave_cleared() -> void: wave_phase = WAVE_PHASE_PEACE wave_timer = 0.0 wave_pending = false 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. if dev_mode and dev_unlimited_resources: _refill_dev_resources() advance_waves(delta) _advance_production(delta) _advance_lab_research(delta) _advance_vessel_deliveries(delta) _advance_garrison_dispatch() _advance_station_dispatch() _advance_planning() _advance_repairs(delta) _advance_resource_converters(delta) _advance_fleet_office_upkeep(delta) _advance_distorder_drain(delta, _distorder_boost_in_use()) _advance_quests() 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) var delivery_speed: float = vessel_stat(String(delivery.type), "delivery_speed") if delivery_speed <= 0.0: delivery_speed = VESSEL_DELIVERY_SPEED delivery.progress = float(delivery.progress) + delta / maxf(distance / delivery_speed, 0.001) if float(delivery.progress) < 1.0: continue vessel_deliveries.remove_at(i) # #104: a vessel bound for an outpost joins its crew. If the outpost # was levelled while it was in the air, or the order was cancelled # under it, it lands in the hangar instead - the vessel is never # destroyed by its destination going away, the same rule a # transporter mid-delivery follows with its cargo. var outpost_id: int = int(delivery.get("to_outpost", 0)) var station_id: int = int(delivery.get("to_station", 0)) if outpost_id != 0 and _garrison_still_wants(outpost_id, String(delivery.type)): garrison_crew(outpost_id).append(String(delivery.type)) elif station_id != 0 and _station_still_wants(station_id): # #216: same rule as an outpost's - a hub levelled or an order # cancelled while the vessel was in the air lands it in the # hangar rather than destroying it. _station_arrived(station_id) else: stored_vessels.append(String(delivery.type)) # --- 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"}. # How many transporters each hub owns. Raised for EVERY hub - built already # or not - by research, and provisioned automatically rather than crafted. 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" # Collecting an output resource (energy) directly from a regular building's # own inventory - a gas burner, not an extractor - and banking it. Reuses # `target_id` exactly like the ordinary supply path does, rather than # `extractor_index`: the source is a building, already addressable that way. const TRANSPORTER_TO_BUILDING_PICKUP: String = "to_building_pickup" # #217: fetching its own fuel. A leg of its own rather than a flag on the # others, so a transporter that is refuelling is visibly doing that and can # never be dispatched onto a cargo run halfway there. const TRANSPORTER_TO_FUEL: String = "to_fuel" const TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: String = "building_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 # #216: a hub used to conjure its own transporters, up to capacity, for free # and forever. They are BUILT now - ordered at a ship crafter, flown out from # the mothership, and worn out by the work they do - so this only ever adds # one that has actually arrived. func _add_hub_transporter(hub: Dictionary) -> void: transporters.append({ "hub_id": int(hub.id), "state": TRANSPORTER_IDLE, "target_id": 0, "extractor_index": -1, "carrying": 0, "resource_type": RESOURCE_ORE, "progress": 1.0, "from": hub.position, "to": hub.position, "trips_left": TRANSPORTER_TRIPS, "fuel": 0, "fuel_source_id": 0, }) 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: # Delivery-purpose states only (#54): a building can have BOTH a # transporter delivering it something (gas, for a burner) and a # separate one collecting something from it (energy) at once, since # they are two different transporters and two different legs. Matching # on target_id alone, regardless of state, would make either purpose # block the other from ever being dispatched to the same building. for t in transporters: if (t.state == TRANSPORTER_TO_MOTHERSHIP or t.state == TRANSPORTER_TO_BUILDING) \ and int(t.target_id) == building_id: return true return false func _transporter_inbound_for_pickup(building_id: int) -> bool: for t in transporters: if (t.state == TRANSPORTER_TO_BUILDING_PICKUP or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING) \ 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 # #217: the nearest place with energy in it, as {position, id} - id 0 meaning # the bank at the mothership, which is where most of it sits. Nearest to the # transporter itself rather than to its hub: it is the one making the trip. func _nearest_energy_source(from: Vector2) -> Dictionary: var best: Dictionary = {} var best_distance: float = INF var mothership: Variant = get_mothership_position() if mothership != null and bank_inventory.amount(RESOURCE_ENERGY) > 0: best = {"position": mothership, "id": 0} best_distance = from.distance_squared_to(mothership) for b in buildings: if building_has_capability(b, "bank"): continue # already considered, as the bank above if building_resource(int(b.id), RESOURCE_ENERGY) <= 0: continue var distance: float = from.distance_squared_to(b.position) if distance < best_distance: best_distance = distance best = {"position": b.position, "id": int(b.id)} return best # An empty tank is worth a detour only once a Burn Drive is researched, and # only while something has energy to give. Runs before ordinary dispatch so a # transporter tops up between jobs rather than crawling through the next one. func _refuel_idle_transporters() -> void: if not transporter_burns_energy(): return for t in transporters: if t.state != TRANSPORTER_IDLE or int(t.get("fuel", 0)) > 0: continue var here: Vector2 = transporter_position(t) var source: Dictionary = _nearest_energy_source(here) if source.is_empty(): continue t.fuel_source_id = int(source.id) _begin_leg(t, here, Vector2(source.position), TRANSPORTER_TO_FUEL) func _dispatch_transporters() -> void: var mothership: Variant = get_mothership_position() if mothership == null: return # nowhere to load from _refuel_idle_transporters() # Supply already-queued production and research before collecting passive # extractor output. Extractors can refill forever, so servicing them first # can otherwise consume every transporter each time one becomes idle and # permanently starve a partly supplied order. for b in buildings: var needed_resource := "" for resource_type in resource_types(): var need: int = building_requested_resource(int(b.id), resource_type) if need > 0 and bank_inventory.amount(resource_type) > 0: needed_resource = resource_type break if needed_resource == "": 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 resources 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) chosen.resource_type = needed_resource _begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP) # 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) # Collect building outputs last: queued deliveries and extractor collection # both outrank resources that are merely ready for pickup. for b in buildings: var pickup_resource: String = _collectable_building_resource(b, load) if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)): continue var chosen_pickup: Dictionary = {} var best_pickup_distance: float = INF for t in transporters: if t.state != TRANSPORTER_IDLE: continue var d: float = transporter_position(t).distance_squared_to(b.position) if d < best_pickup_distance: best_pickup_distance = d chosen_pickup = t if chosen_pickup.is_empty(): return chosen_pickup.target_id = int(b.id) chosen_pickup.resource_type = pickup_resource chosen_pickup.extractor_index = -1 _begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position, TRANSPORTER_TO_BUILDING_PICKUP) func _advance_transporters(delta: float) -> void: if transporters.is_empty(): return _dispatch_transporters() # Duplicated for the same reason as the drones: a spent transporter is # removed from this list on arrival. for t in transporters.duplicate(): 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_leg_speed(t), 0.0001) t.progress += delta / leg if t.progress < 1.0: continue t.progress = 1.0 # #217: a unit of energy per leg flown under boost, spent on arrival # so a leg is either paid for in full or not flown under boost at all. # Refuelling legs are not charged - a transporter would otherwise burn # the fuel it is on its way to collect. if int(t.get("fuel", 0)) > 0 and String(t.state) != TRANSPORTER_TO_FUEL: t.fuel = int(t.fuel) - 1 _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 resource_type: String = String(t.get("resource_type", RESOURCE_ORE)) var want: int = building_requested_resource(int(t.target_id), resource_type) var take: int = mini(mini(want, bank_inventory.amount(resource_type)), 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_type, take) t.carrying = take _begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING) TRANSPORTER_TO_BUILDING: # The cargo moves at exactly the moment the vessel arrives. var resource_type: String = String(t.get("resource_type", RESOURCE_ORE)) if deposit_resource_to_building(int(t.target_id), resource_type, int(t.carrying)) <= 0: # Building demolished in transit - put the cargo back rather # than quietly destroying it. bank_inventory.deposit(resource_type, 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_TO_BUILDING_PICKUP: var source: Dictionary = find_building(int(t.target_id)) var resource_type: String = String(t.get("resource_type", RESOURCE_ENERGY)) var take: int = 0 if source.is_empty() \ else mini(building_resource(int(t.target_id), resource_type), transporter_load_limit()) if take <= 0 or source.is_empty(): t.target_id = 0 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) return _inventory_for_record(source).withdraw(resource_type, take) t.carrying = take _begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING) TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ENERGY)), 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_FUEL: # Whatever is actually there, up to the tank. A store drained by # something else while this was in the air simply means a shorter # fill, not a wasted trip. var tank: int = transporter_fuel_capacity() - int(t.get("fuel", 0)) var source_id: int = int(t.get("fuel_source_id", 0)) var drawn: int = 0 if source_id == 0: drawn = mini(tank, bank_inventory.amount(RESOURCE_ENERGY)) bank_inventory.withdraw(RESOURCE_ENERGY, drawn) else: drawn = mini(tank, building_resource(source_id, RESOURCE_ENERGY)) var store: RefCounted = building_inventory(source_id) if store != null: store.withdraw(RESOURCE_ENERGY, drawn) else: drawn = 0 t.fuel = int(t.get("fuel", 0)) + drawn t.fuel_source_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 # #216: a round trip is what wears one out, counted where the trip # ENDS. Counting at dispatch would retire a vessel with its cargo # still in the air. t.trips_left = int(t.get("trips_left", TRANSPORTER_TRIPS)) - 1 if int(t.trips_left) <= 0: _retire_transporter(t) # --- staffing hubs and workshops (#216) ------------------------------------ # # Transporters and repair drones used to appear with their building, free and # permanent. They are ordinary craft now: ordered at a ship crafter, parked in # the mothership hangar, flown out to whichever structure asked for them, and # worn out by the work they do. What a structure HAS and what it was ASKED to # have are separate, exactly as they are for an outpost garrison (#104), so a # hub waiting on a hull it has not built yet reads as waiting rather than as # broken. # # How much work one has in it before it is spent. Round numbers rather than a # curve: the point the ticket makes is that they run out and have to be # replaced, and a player has to be able to hold "about twenty runs" in their # head while deciding whether to build another. const TRANSPORTER_TRIPS: int = 20 const DRONE_REPAIRS: int = 12 # Both already exist above - VESSEL_TRANSPORTER with the other vessel ids, # REPAIR_DRONE with the workshop - so they are used rather than redeclared. # Which vessel staffs this structure, or "" for one that is not staffed. func station_vessel_type(building: Dictionary) -> String: if building_has_capability(building, "transport_hub"): return VESSEL_TRANSPORTER if building_has_capability(building, "repair"): return REPAIR_DRONE return "" func station_capacity(building: Dictionary) -> int: match station_vessel_type(building): VESSEL_TRANSPORTER: return hub_capacity() REPAIR_DRONE: return workshop_drone_capacity() return 0 func station_desired(id: int) -> int: var building: Dictionary = find_building(id) if building.is_empty(): return 0 return mini(int(building.get("staff_desired", 0)), station_capacity(building)) # Returns what was actually set, so a caller can show a clamped request rather # than silently doing something else - same contract as set_garrison_desired. func set_station_desired(id: int, count: int) -> int: var building: Dictionary = find_building(id) if building.is_empty() or station_vessel_type(building) == "": return 0 building.staff_desired = clampi(count, 0, station_capacity(building)) return int(building.staff_desired) # How many are actually standing there. Counts the crew, not the order: one # still in the air has not started working yet. func station_crew(id: int) -> int: var building: Dictionary = find_building(id) var n: int = 0 match station_vessel_type(building): VESSEL_TRANSPORTER: for transporter in transporters: if int(transporter.get("hub_id", 0)) == id: n += 1 REPAIR_DRONE: for drone in repair_drones: if int(drone.get("workshop_id", 0)) == id: n += 1 return n # What is left in each of them, worst first, for the readout in the building. # A structure whose crew is nearly spent should look nearly spent. func station_condition(id: int) -> Array: var building: Dictionary = find_building(id) var left: Array = [] match station_vessel_type(building): VESSEL_TRANSPORTER: for transporter in transporters: if int(transporter.get("hub_id", 0)) == id: left.append(int(transporter.get("trips_left", TRANSPORTER_TRIPS))) REPAIR_DRONE: for drone in repair_drones: if int(drone.get("workshop_id", 0)) == id: left.append(int(drone.get("repairs_left", DRONE_REPAIRS))) left.sort() return left func station_work_capacity(id: int) -> int: var building: Dictionary = find_building(id) match station_vessel_type(building): VESSEL_TRANSPORTER: return TRANSPORTER_TRIPS REPAIR_DRONE: return DRONE_REPAIRS return 0 # One in the air, so a structure short of crew does not order a second hull # for the same berth while the first is still on its way. func _station_inbound(id: int) -> int: var n: int = 0 for delivery in vessel_deliveries: if int(delivery.get("to_station", 0)) == id: n += 1 return n func _advance_station_dispatch() -> void: var mothership: Variant = get_mothership_position() if mothership == null: return for building in buildings: var vessel_type: String = station_vessel_type(building) if vessel_type == "": continue var id: int = int(building.id) var short: int = station_desired(id) - station_crew(id) - _station_inbound(id) while short > 0 and _take_stored_vessel(vessel_type): vessel_deliveries.append({"type": vessel_type, "from": mothership, "to": building.position, "progress": 0.0, "to_station": id}) short -= 1 func _station_still_wants(id: int) -> bool: var building: Dictionary = find_building(id) if building.is_empty() or station_vessel_type(building) == "": return false return station_crew(id) < station_desired(id) func _station_arrived(id: int) -> void: var building: Dictionary = find_building(id) match station_vessel_type(building): VESSEL_TRANSPORTER: _add_hub_transporter(building) REPAIR_DRONE: _add_workshop_drone(building) # A spent vessel is GONE, not returned to the hangar: it was used up. The # structure is short again, and the dispatch above will send another as soon # as the player has built one. func _retire_transporter(t: Dictionary) -> void: var index: int = transporters.find(t) if index >= 0: transporters.remove_at(index) func _retire_drone(drone: Dictionary) -> void: var index: int = repair_drones.find(drone) if index >= 0: repair_drones.remove_at(index) # --- quests (#129, #131) --------------------------------------------------- # The chain lives in Quests.gd; what lives here is how far along it the # player is, and the answer to "is this one done yet". # # Progress LATCHES, and it latches for free: the chain is walked one quest # at a time, and the moment a goal is met the index moves on and never comes # back. So collecting five ore and then spending it cannot un-complete the # quest that asked for five ore - by the time it is spent, nothing is # asking any more. That is why goals are allowed to be phrased against # current holdings rather than against a running total nobody would keep in # step. # # It sits in GameState with everything else that has to survive the # Main.tscn <-> PlanetSurface.tscn swap - a quest completed on a surface # must still be completed after take-off. signal quest_started(id: String) signal quest_completed(id: String) # Whether this playthrough has a quest chain at all. Armed by # reset_new_game(), which is the only door into a real game (the main menu's # Play, and start_dev_game on top of it) - exactly the gate intro_active # already uses. Without it, every scene ever instantiated would open on the # first quest's briefing, including the ones in the test suite that only # want a world to look at. var quests_armed: bool = false var quest_index: int = 0 # Whether the current quest has been ANNOUNCED. Kept apart from the index so # a quest that is already satisfied the moment it starts still gets a frame # on screen with its own name on it, rather than being skipped silently. var quest_announced: bool = false # Lines the chain wants said, waiting for something that can say them. # # Queued here rather than emitted straight at a banner because quests # advance in this autoload, which runs on the planet surface too - where # there is no narration banner at all. A line emitted there would simply be # lost. QuestDirector drains this whenever it is alive, so a quest completed # underground is read out on the way back up. var quest_messages: Array = [] # Which planets have been LANDED ON, and which kinds of world they were. # Distinct from explored_chunks, which is only about having flown past. var visited_planets: Dictionary = {} var visited_planet_kinds: Dictionary = {} # #173: the catalog is checked once, here, in every build - not only in the # test suite. The editor can quietly strip a property out of a .tres between # the last test run and the export, and the shipped result is a game that # looks fine and cannot mine. push_error rather than a crash: the game is # still playable in whatever way survived, and on the web these lines reach # the on-page log web_shell.html mirrors from the console, which is the only # error a phone can read. func _ready() -> void: catalog_faults = CATALOG_CHECK.faults() if catalog_faults.is_empty(): return push_error("The definition catalog is damaged - see core/CatalogCheck.gd. %d fault(s):" \ % catalog_faults.size()) for fault in catalog_faults: push_error(" catalog: %s" % fault) func reset_quests(armed: bool = false) -> void: quests_armed = armed quest_index = 0 quest_announced = false quest_messages.clear() visited_planets.clear() visited_planet_kinds.clear() # Called when the fleet actually sets down on a world. func note_planet_visited(id: String, definition_id: String) -> void: if id != "": visited_planets[id] = true if definition_id != "": visited_planet_kinds[definition_id] = true func quest_count() -> int: return QUESTS.count() func quest_chain_complete() -> bool: return quest_index >= QUESTS.count() # The quest the player is on, or {} once the chain is done. func active_quest() -> Dictionary: return QUESTS.at(quest_index) # One quest by position in the chain, for anything listing what is behind the # player (#140). Out of range answers {} rather than erroring. func quest_at(index: int) -> Dictionary: return QUESTS.at(index) # How far along one quest is, as (have, need). One line per goal kind, and # every one of them is answered from state this autoload already holds - a # goal that needed a node to ask would be a goal that stops working on a # planet surface. func quest_progress(quest: Dictionary) -> Vector2i: if quest.is_empty(): return Vector2i.ZERO var goal: Dictionary = quest.get("goal", {}) match String(goal.get("kind", "")): QUESTS.GOAL_RESOURCE: var want: int = int(goal.get("amount", 1)) return Vector2i(mini(available_resource(String(goal.get("resource", RESOURCE_ORE))), want), want) QUESTS.GOAL_BUILDING: return Vector2i(1 if structure_exists(String(goal.get("building", ""))) else 0, 1) QUESTS.GOAL_FLEET_VESSEL: var need: int = int(goal.get("count", 1)) return Vector2i(mini(vessel_count_in_fleet(String(goal.get("vessel", ""))), need), need) QUESTS.GOAL_VESSEL_UNLOCKED: return Vector2i(1 if vessel_is_unlocked(String(goal.get("vessel", ""))) else 0, 1) QUESTS.GOAL_RESEARCH: return Vector2i(1 if is_unlocked(String(goal.get("node", ""))) else 0, 1) QUESTS.GOAL_GARRISONED: var stationed: int = int(goal.get("count", 1)) return Vector2i(mini(garrisoned_vessel_count(), stationed), stationed) QUESTS.GOAL_BANKED: var banked: int = int(goal.get("amount", 1)) return Vector2i(mini(banked_resource(String(goal.get("resource", RESOURCE_ORE))), banked), banked) QUESTS.GOAL_EXPLORE: var sectors: int = int(goal.get("sectors", 1)) return Vector2i(mini(explored_chunks.size(), sectors), sectors) QUESTS.GOAL_PLANET_VISITS: var visits: int = int(goal.get("count", 1)) return Vector2i(mini(visited_planets.size(), visits), visits) QUESTS.GOAL_PLANET_KINDS: var kinds: int = int(goal.get("count", 1)) return Vector2i(mini(visited_planet_kinds.size(), kinds), kinds) QUESTS.GOAL_CONQUERED: var wanted: int = int(goal.get("count", 1)) var held: int = 0 for key in planet_capture: if bool(planet_capture[key].get("conquered", false)): held += 1 return Vector2i(mini(held, wanted), wanted) # An unknown goal kind must not be quietly "already done" - that would # run the whole chain off in one frame. return Vector2i(0, 1) func quest_goal_met(quest: Dictionary) -> bool: if quest.is_empty(): return false var progress: Vector2i = quest_progress(quest) return progress.x >= progress.y func say_quest_line(text: String) -> void: if text.strip_edges() == "": return quest_messages.append({"line": text, "speaker": QUESTS.SPEAKER}) # Hands over every line waiting to be said, and empties the queue. Drained # rather than read so two directors could never read the same line twice. func take_quest_messages() -> Array: var taken: Array = quest_messages.duplicate() quest_messages.clear() return taken # Ends the current quest whether or not its goal was met. The chain's only # way forward, used by _advance_quests below and by the tests. # # #167: `announce` is what a SKIPPED quest turns off. The index still moves # and quest_completed still fires - the quest is credited exactly as if it # had been played - but nothing is queued for the banner to read out. func complete_active_quest(announce: bool = true) -> bool: var quest: Dictionary = active_quest() if quest.is_empty(): return false if announce: say_quest_line(String(quest.get("done", ""))) quest_index += 1 quest_announced = false quest_completed.emit(String(quest.id)) return true # #167: a quest handed out with its goal already met, credited in silence. # # Both signals still fire, in the order they would have fired in had it been # played, so anything watching the chain sees an ordinary start-then-finish # rather than a quest that was never begun. What it does NOT do is speak: # neither the brief nor the answer reaches quest_messages, so the game is # never stopped twice to be told about something already done. func skip_active_quest() -> bool: var quest: Dictionary = active_quest() if quest.is_empty(): return false quest_started.emit(String(quest.id)) return complete_active_quest(false) func _advance_quests() -> void: # The prologue is already talking, and the fleet in it is not the # player's. The chain starts when the prologue hands over. if not quests_armed or intro_active or quest_chain_complete(): return var quest: Dictionary = active_quest() if not quest_announced: # #167: the chain hands out teaching quests, and by the time some of # them come up the player has already done the thing - the extractor # is down, the hangar is crewed. Announcing one of those stops the # game to explain a task, then stops it again to congratulate them # on a task they never did. So an unimportant quest whose goal is # already met is banked without a word (see Quests.is_important); # it still shows up as done in the objectives panel, which is where # the player looks for what they have been credited with. if quest_goal_met(quest) and not QUESTS.is_important(quest): skip_active_quest() # One a frame. Several already-met quests in a row are cleared # over the next few frames rather than in a loop here, which # keeps this function's shape - one step per call - and keeps a # malformed chain from spinning inside a single tick. return quest_announced = true say_quest_line(String(quest.get("brief", ""))) quest_started.emit(String(quest.id)) # Deliberately no completion check this frame: a quest whose goal # happens to be met already still gets read out before it closes. return if quest_goal_met(quest): complete_active_quest() # --- paying for things ------------------------------------------------------ # # Payments draw on the bank and may also draw on what the ships are carrying # when the caller supplies the concrete place being paid. The player can move # resources between the two by hand, so using nearby cargo avoids ceremony; # #180 makes "nearby" part of the API instead of an assumption in the caller. # # In one case it was worse than ceremony. A fresh game starts with an empty # bank, ore is mined into the fleet hold, and the only way to bank it is to # deliver it to a mothership - so charging construction to the bank alone # made the first mothership cost ore that could not yet exist. Mine forever, # build nothing. # # Bank first, nearby hold second. A call with no position is deliberately # bank-only: missing context must fail safe instead of teleporting cargo. # # The one deliberate exception is AMMO - see AimedFleetAbilityController, # which checks fleet_resource() directly. A gas bomb is fired from what the # ships are physically carrying; that is a fact about where the matter is, # not a convenience about where the money is. func available_resource(resource_type: String) -> int: return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type) # What can pay at one concrete place. Banked resources remain globally # available; cargo in the fleet hold joins them only when the fleet is close # enough to physically hand it to the building or construction site (#180). # A null position deliberately means "no local payer", so a forgotten context # can never reach into the hold from across the map. func available_resource_at(resource_type: String, payment_position: Variant = null) -> int: var amount: int = bank_inventory.amount(resource_type) if fleet_can_pay_at(payment_position): amount += fleet_inventory.amount(resource_type) return amount func available_ore() -> int: return available_resource(RESOURCE_ORE) func can_afford(cost: int, payment_position: Variant = null) -> bool: return available_resource_at(RESOURCE_ORE, payment_position) >= cost func can_afford_resources(costs: Dictionary, payment_position: Variant = null) -> bool: for resource_type in costs: if available_resource_at(String(resource_type), payment_position) \ < int(costs[resource_type]): return false return true func spend(cost: int, payment_position: Variant = null) -> bool: return spend_resources({RESOURCE_ORE: cost}, payment_position) func spend_resources(costs: Dictionary, payment_position: Variant = null) -> bool: # Affordability is settled in full BEFORE anything is taken. Charging for # part of a purchase that then fails is worse than refusing it, and with # several resource types in one price that is a real possibility rather # than a theoretical one. if not can_afford_resources(costs, payment_position): return false var fleet_can_pay: bool = fleet_can_pay_at(payment_position) for resource_type in costs: var id: String = String(resource_type) var owed: int = int(costs[resource_type]) owed -= bank_inventory.withdraw(id, owed) if owed > 0 and fleet_can_pay: fleet_inventory.withdraw(id, owed) return true