extends Node2D const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") # Space-side building UI. Owns: # - the build menu (place a mothership, ship crafter or command centre) # - the shared ACTION button, which adapts to whatever the fleet is # standing on (craft ships, configure formation, or land). One action # fires directly; multiple actions unfold horizontally. # - rebuilding a wiped-out fleet at the mothership # # Buildings themselves live in GameState (they must outlive the trip to a # planet's surface); this only drives the UI and spends resources. @export var prompt_debounce_time: float = 0.15 @onready var fleet: Node2D = get_node("../Fleet") @onready var building_field: Node2D = get_node("../BuildingField") @onready var world_map: Control = get_node("../UI/WorldMap") @onready var build_button: IconButton = get_node("../UI/BuildButton") @onready var action_button: IconButton = get_node("../UI/BuildActionButton") @onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton") @onready var landing_button: IconButton = get_node("../UI/EnterAtmosphereButton") @onready var landing_controller: Node2D = get_node("../LandingController") @onready var build_menu: Control = get_node("../UI/BuildMenu") @onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton") @onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") @onready var lab_menu: Control = get_node("../UI/LabMenu") @onready var lab_vessel_buttons: Array = [ get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"), get_node("../UI/LabMenu/VesselE"), get_node("../UI/LabMenu/VesselF"), get_node("../UI/LabMenu/VesselG"), ] @onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel") @onready var lab_vessel_labels: Array = [ get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"), get_node("../UI/LabMenu/VesselCLabel"), get_node("../UI/LabMenu/VesselDLabel"), get_node("../UI/LabMenu/VesselELabel"), get_node("../UI/LabMenu/VesselFLabel"), get_node("../UI/LabMenu/VesselGLabel"), ] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") @onready var lab_buy_button: IconButton = get_node("../UI/LabMenu/BuyButton") @onready var lab_close_button: IconButton = get_node("../UI/LabMenu/CloseButton") @onready var lab_upgrade_buttons: Array = [ get_node("../UI/LabMenu/Upgrade0"), get_node("../UI/LabMenu/Upgrade1"), get_node("../UI/LabMenu/Upgrade2"), get_node("../UI/LabMenu/Upgrade3"), ] @onready var lab_upgrade_labels: Array = [ get_node("../UI/LabMenu/Upgrade0Label"), get_node("../UI/LabMenu/Upgrade1Label"), get_node("../UI/LabMenu/Upgrade2Label"), get_node("../UI/LabMenu/Upgrade3Label"), ] @onready var craft_shield_button: IconButton = get_node("../UI/CraftMenu/ShieldButton") @onready var queue_slots: Array = [ get_node("../UI/CraftMenu/QueueSlot0"), get_node("../UI/CraftMenu/QueueSlot1"), get_node("../UI/CraftMenu/QueueSlot2"), get_node("../UI/CraftMenu/QueueSlot3"), ] @onready var queue_detail: Label = get_node("../UI/CraftMenu/QueueDetail") @onready var hangar_button: IconButton = get_node("../UI/HangarButton") @onready var hangar_menu: Control = get_node("../UI/HangarMenu") @onready var hangar_status: Label = get_node("../UI/HangarMenu/StatusLabel") @onready var hangar_close_button: IconButton = get_node("../UI/HangarMenu/CloseButton") @onready var hangar_available_slots: Array = [ get_node("../UI/HangarMenu/AvailableInterceptor"), get_node("../UI/HangarMenu/AvailableShield"), get_node("../UI/HangarMenu/AvailableGasBomber"), get_node("../UI/HangarMenu/AvailableCarrier"), get_node("../UI/HangarMenu/AvailableLaser"), get_node("../UI/HangarMenu/AvailableDistorder"), ] @onready var hangar_available_labels: Array = [ get_node("../UI/HangarMenu/AvailableInterceptorLabel"), get_node("../UI/HangarMenu/AvailableShieldLabel"), get_node("../UI/HangarMenu/AvailableGasBomberLabel"), get_node("../UI/HangarMenu/AvailableCarrierLabel"), get_node("../UI/HangarMenu/AvailableLaserLabel"), get_node("../UI/HangarMenu/AvailableDistorderLabel"), ] @onready var hangar_fleet_slots: Array = [ get_node("../UI/HangarMenu/FleetInterceptor"), get_node("../UI/HangarMenu/FleetShield"), get_node("../UI/HangarMenu/FleetGasBomber"), get_node("../UI/HangarMenu/FleetCarrier"), get_node("../UI/HangarMenu/FleetLaser"), get_node("../UI/HangarMenu/FleetDistorder"), ] @onready var hangar_fleet_labels: Array = [ get_node("../UI/HangarMenu/FleetInterceptorLabel"), get_node("../UI/HangarMenu/FleetShieldLabel"), get_node("../UI/HangarMenu/FleetGasBomberLabel"), get_node("../UI/HangarMenu/FleetCarrierLabel"), get_node("../UI/HangarMenu/FleetLaserLabel"), get_node("../UI/HangarMenu/FleetDistorderLabel"), ] var build_menu_entries: Array = [] # Which vessel the lab screen is showing, and which visible upgrade card is # selected. Each vessel gets its own small set of direct cards; the detail and # Buy controls below them only describe the one the player tapped. var lab_vessel: String = GameState.VESSEL_INTERCEPTOR var lab_choice: int = 0 var lab_building_id: int = 0 @onready var planet_field: Node2D = get_node("../PlanetField") @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel") @onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber") @onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton") @onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton") @onready var craft_menu: Control = get_node("../UI/CraftMenu") @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") @onready var craft_interceptor_label: Label = get_node("../UI/CraftMenu/InterceptorLabel") @onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel") @onready var craft_gas_bomber_button: IconButton = get_node("../UI/CraftMenu/GasBomberButton") @onready var craft_gas_bomber_label: Label = get_node("../UI/CraftMenu/GasBomberLabel") @onready var craft_carrier_button: IconButton = get_node("../UI/CraftMenu/CarrierButton") @onready var craft_carrier_label: Label = get_node("../UI/CraftMenu/CarrierLabel") @onready var craft_laser_button: IconButton = get_node("../UI/CraftMenu/LaserButton") @onready var craft_laser_label: Label = get_node("../UI/CraftMenu/LaserLabel") @onready var craft_distorder_button: IconButton = get_node("../UI/CraftMenu/DistorderButton") @onready var craft_distorder_label: Label = get_node("../UI/CraftMenu/DistorderLabel") @onready var craft_vessel_buttons: Array = [ craft_interceptor_button, craft_shield_button, craft_gas_bomber_button, craft_carrier_button, craft_laser_button, craft_distorder_button, ] @onready var craft_vessel_labels: Array = [ craft_interceptor_label, craft_shield_label, craft_gas_bomber_label, craft_carrier_label, craft_laser_label, craft_distorder_label, ] # --- the transfer panel (#24) --------------------------------------------- # Ore used to be moved from inside whichever panel happened to be open: "load # ore" lived in the research screen, "store ore here" in the ship crafter. # Both are now one top-level shortcut beside Build at resource-bearing # locations and work in both directions, so moving cargo is never buried # under an unrelated action foldout. @onready var transfer_button: IconButton = get_node("../UI/TransferButton") @onready var transfer_menu: Control = get_node("../UI/TransferMenu") @onready var transfer_status: Label = get_node("../UI/TransferMenu/StatusLabel") @onready var transfer_close_button: IconButton = get_node("../UI/TransferMenu/CloseButton") @onready var transfer_list: ResourceTransferList = get_node("../UI/TransferMenu/ResourceList") # Every building menu carries the same header (#24). Held as a list because # the only thing any of them needs is to be told which building it is # reporting on when its panel opens. @onready var resource_bars: Array = [ get_node("../UI/CraftMenu/Bar"), get_node("../UI/SkillMenu/Bar"), get_node("../UI/LabMenu/Bar"), ] @onready var skill_menu: Control = get_node("../UI/SkillMenu") @onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree") @onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel") @onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber") @onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel") @onready var skill_unlock_button: IconButton = get_node("../UI/SkillMenu/UnlockButton") @onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton") @onready var formation_menu: Control = get_node("../UI/FormationMenu") @onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor") @onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton") @onready var formation_reset_button: IconButton = get_node("../UI/FormationMenu/ResetButton") # Which structure the popup is currently offering to build, or "" when it is # only explaining something that cannot be built right now. var pending_build_type: String = "" # Which crafter the craft menu is showing (see _on_action_pressed). var craft_building_id: int = 0 # The craft panel refreshes every frame while it is open (so a delivery # arriving visibly works the queue down). That overwrote any message the # moment after it was set, which made a working button look dead - the store # button in particular. Messages therefore have to outlive a refresh. var craft_message: String = "" var craft_message_until: int = 0 # Which building the transfer panel is moving ore in and out of. Remembered # for the same reason craft_building_id is: _process() clears # current_building while any menu is open, so by the next frame the panel # would not know which building it belongs to. var transfer_building_id: int = 0 var transfer_extractor_index: int = -1 var current_building: Node2D = null var pending_building: Node2D = null var current_extractor_index: int = -1 var pending_extractor_index: int = -1 var current_landing_planet: Node2D = null var pending_time: float = 0.0 var context_actions_expanded: bool = false func _ready() -> void: build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) _configure_build_menu_entries() lab_close_button.pressed.connect(func(): lab_menu.visible = false) lab_buy_button.pressed.connect(_on_lab_buy) for i in range(lab_vessel_buttons.size()): lab_vessel_buttons[i].pressed.connect(_on_lab_vessel.bind(i)) for i in range(lab_upgrade_buttons.size()): lab_upgrade_buttons[i].pressed.connect(_on_lab_upgrade.bind(i)) info_close_button.pressed.connect(_close_info) info_build_button.pressed.connect(_confirm_build) action_button.pressed.connect(_on_context_action_pressed) building_action_button.pressed.connect(_on_action_pressed) for i in range(mini(craft_vessel_buttons.size(), VESSEL_DEFINITIONS.fleet().size())): var definition: Resource = VESSEL_DEFINITIONS.fleet()[i] craft_vessel_buttons[i].pressed.connect(_on_craft_vessel.bind(definition.definition_id)) craft_close_button.pressed.connect(func(): craft_menu.visible = false) transfer_button.pressed.connect(_on_transfer_pressed) hangar_button.pressed.connect(_on_hangar_pressed) hangar_close_button.pressed.connect(func(): hangar_menu.visible = false) for i in range(hangar_available_slots.size()): hangar_available_slots[i].pressed.connect(_on_hangar_crew_type.bind(i)) hangar_fleet_slots[i].pressed.connect(_on_hangar_store_type.bind(i)) for i in range(queue_slots.size()): queue_slots[i].pressed.connect(_on_queue_slot.bind(i)) transfer_close_button.pressed.connect(func(): transfer_menu.visible = false) transfer_list.transfer_requested.connect(_on_resource_transfer) formation_close_button.pressed.connect(func(): formation_menu.visible = false) formation_reset_button.pressed.connect(_on_reset_formation) formation_editor.formation_changed.connect(_on_formation_changed) skill_close_button.pressed.connect(func(): skill_menu.visible = false) skill_unlock_button.pressed.connect(_on_unlock_pressed) skill_tree.node_picked.connect(_on_skill_node_picked) build_menu.visible = false info_popup.visible = false craft_menu.visible = false formation_menu.visible = false skill_menu.visible = false lab_menu.visible = false transfer_menu.visible = false action_button.visible = false building_action_button.visible = false landing_button.visible = false transfer_button.visible = false hangar_button.visible = false hangar_menu.visible = false _layout_context_buttons() func _configure_build_menu_entries() -> void: build_menu_entries.clear() for definition in BUILDING_DEFINITIONS.all(): if definition.menu_button_name == "": continue var button: IconButton = get_node("../UI/BuildMenu/%s" % definition.menu_button_name) var label: Label = get_node("../UI/BuildMenu/%s" % definition.menu_label_name) var cost_number: NeonNumber = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name) button.icon = definition.menu_icon button.pressed.connect(_show_building_info.bind(String(definition.definition_id))) label.text = definition.display_name cost_number.amount = definition.cost build_menu_entries.append([definition.definition_id, button, label, cost_number, definition]) # True while a modal panel owns the shared context controls. A plain building # prompt is not modal: landing can coexist with its actions in the same foldout. func is_showing_building_prompt() -> bool: return build_menu.visible or craft_menu.visible \ or formation_menu.visible or skill_menu.visible or transfer_menu.visible \ or hangar_menu.visible func _unhandled_input(event: InputEvent) -> void: if context_actions_expanded and (event is InputEventScreenTouch and event.pressed \ or event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT): # Buttons consume their own touch; reaching this handler means the tap # landed somewhere else, which dismisses an expanded action list. _collapse_context_actions() # The formation editor and the skill tree are driven from here rather than # via _gui_input, for the same reason as the joystick: a Control that # consumes touches through the GUI system swallows them before they get # here, which is what made dragging a formation slot silently do nothing. if skill_menu.visible: if event is InputEventScreenTouch: skill_tree.handle_touch(event) elif event is InputEventScreenDrag: skill_tree.handle_drag(event) # Handled either way. A touch that lands on the panel but misses a # node is still a touch ON THE PANEL - letting it through moved the # fleet and pinch-zoomed the world behind the menu, because # GameController sits on the root node and sees whatever this leaves # unhandled. get_viewport().set_input_as_handled() return if formation_menu.visible: if event is InputEventScreenTouch: formation_editor.handle_touch(event) elif event is InputEventScreenDrag: formation_editor.handle_drag(event) get_viewport().set_input_as_handled() return if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible \ or transfer_menu.visible or hangar_menu.visible: # These panels have no raw-input handler of their own, but the world # behind them must still not react. get_viewport().set_input_as_handled() func _process(delta: float) -> void: _check_fleet_wipe() if build_menu.visible: # Keep the greyed state live while the menu is open, so flying onto # a planet lights the option up without reopening it. _refresh_build_option_states() if craft_menu.visible: # Live, so a delivery arriving while the menu is open visibly works # the queue down instead of looking stuck. _refresh_craft_status() if transfer_menu.visible: # Also live: a hauler banking ore while the panel is open should make # the "to fleet" side become available without a reopen. _refresh_transfer() if world_map.visible or build_menu.visible or craft_menu.visible \ or formation_menu.visible or skill_menu.visible or lab_menu.visible \ or transfer_menu.visible or hangar_menu.visible: current_building = null pending_building = null current_extractor_index = -1 pending_extractor_index = -1 current_landing_planet = null action_button.visible = false building_action_button.visible = false landing_button.visible = false transfer_button.visible = false hangar_button.visible = false context_actions_expanded = false return # Debounced exactly like the enter-atmosphere prompt, so the action # button can't flicker while the fleet drifts on the interact boundary. var detected: Node2D = building_field.get_building_fleet_is_on() var detected_extractor: int = -1 if detected == null: var planet: Node2D = _planet_under_fleet() if planet != null: detected_extractor = GameState.extractor_index_at(planet.planet_id) if detected != pending_building or detected_extractor != pending_extractor_index: pending_building = detected pending_extractor_index = detected_extractor pending_time = 0.0 else: pending_time += delta var detected_landing_planet: Node2D = landing_controller.current_planet if pending_time < prompt_debounce_time or (pending_building == current_building \ and pending_extractor_index == current_extractor_index \ and detected_landing_planet == current_landing_planet): return current_building = pending_building current_extractor_index = pending_extractor_index current_landing_planet = detected_landing_planet context_actions_expanded = false hangar_button.visible = false building_action_button.visible = false landing_button.visible = false transfer_button.visible = _has_transfer_context() var actions: Array = _context_action_buttons() action_button.visible = not actions.is_empty() if current_building != null: building_action_button.icon = int(current_building.definition.action_icon) building_action_button.queue_redraw() if not actions.is_empty(): action_button.icon = int(actions[0].icon) if actions.size() == 1 else IconButton.Icon.ACTION_MENU action_button.queue_redraw() _layout_context_buttons() func _layout_context_buttons() -> void: # Build is permanently under the resource counter. The contextual berth # directly below it is compact until it has more than one real choice. action_button.offset_top = 324 action_button.offset_bottom = 472 var buttons: Array = _context_action_buttons() if not context_actions_expanded: for button in buttons: button.visible = false return # Expand sideways so the controls stay in the same visual band instead of # pushing the player down a vertical stack. Three actions fit across the # 720px phone layout with the same generous 148px touch targets. for i in range(buttons.size()): var button: Control = buttons[i] button.visible = true button.offset_left = 188 + i * 164 button.offset_right = button.offset_left + 148 button.offset_top = 324 button.offset_bottom = 472 func _context_action_buttons() -> Array: var buttons: Array = [] if current_building != null: for action in current_building.available_actions(): var button: IconButton = _button_for_building_action(action) if button != null and button not in buttons: buttons.append(button) if current_landing_planet != null: buttons.append(landing_button) return buttons func _has_transfer_context() -> bool: if current_extractor_index >= 0: return true return current_building != null and current_building.available_actions().has("transfer") func _button_for_building_action(action: String) -> IconButton: if action == "transfer": return null if action == "hangar": return hangar_button return building_action_button func _on_context_action_pressed() -> void: if current_building == null and current_extractor_index < 0 \ and current_landing_planet == null: return var actions: Array = _context_action_buttons() if actions.size() == 1: if actions[0] == landing_button: landing_controller._on_enter_pressed() elif actions[0] == hangar_button: _on_hangar_pressed() else: _on_action_pressed() return context_actions_expanded = not context_actions_expanded _layout_context_buttons() func _collapse_context_actions() -> void: if not context_actions_expanded: return context_actions_expanded = false _layout_context_buttons() func _check_fleet_wipe() -> void: if not fleet.ships.is_empty(): return # Whole fleet destroyed: rebuild a single vessel at the mothership, or # at the world origin when none has been built yet. var spawn_pos: Variant = GameState.get_mothership_position() fleet.spawn_ships(1, spawn_pos if spawn_pos != null else Vector2.ZERO) func _on_action_pressed() -> void: if current_building == null: return # Whichever panel opens, its header reports on the building it was opened # from. Set once here rather than in each branch, so a new building menu # cannot forget to do it and silently show a blank HERE column. for bar in resource_bars: bar.building_id = current_building.building_id var action: String = current_building.primary_action() if action == "research": skill_menu.visible = true skill_tree.reset_scroll() _refresh_skill_menu() elif action == "craft": # Remembered explicitly: _process() clears current_building while any # menu is open, so by the next frame the panel would no longer know # which crafter it belongs to. Losing this line makes every button in # the panel bail out at its `== 0` guard while the panel itself still # opens - which reads as "the buttons do nothing". craft_building_id = current_building.building_id craft_menu.visible = true _refresh_craft_status() elif action == "lab": lab_building_id = current_building.building_id lab_menu.visible = true _refresh_lab() elif action == "formation": formation_menu.visible = true formation_editor.queue_redraw() func _on_build_pressed() -> void: build_menu.visible = true info_popup.visible = false pending_build_type = "" _refresh_build_status() func _planet_under_fleet() -> Node2D: # An extractor mines a specific planet, so one has to be within reach. return planet_field.get_planet_fleet_is_on() # Grid geometry, shared with the layout below. Rows of two, and the menu is # rebuilt from whatever is currently researched (#21) rather than being a # fixed arrangement with holes punched in it. const MENU_ROW_TOPS: Array = [-300.0, -77.0, 146.0, 369.0] const MENU_COL_X: float = 140.0 const MENU_BUTTON_HALF: float = 64.0 const MENU_BUTTON_H: float = 100.0 const MENU_LABEL_HALF: float = 150.0 const MENU_LABEL_H: float = 52.0 const MENU_COST_HALF: float = 60.0 const MENU_COST_H: float = 44.0 # Gaps from each row's own top to where the label/cost start, sized to the # shrunk MENU_*_H values above (#54: fitting a 4th row meant tightening # MENU_ROW_TOPS' spacing to 223px, less than the original 256px a row's # button+label+cost needed - shrinking those closed the gap without # resorting to a scrollable menu). const MENU_LABEL_OFFSET: float = 106.0 const MENU_COST_OFFSET: float = 162.0 func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void: node.offset_left = cx - half node.offset_right = cx + half node.offset_top = top node.offset_bottom = top + height func _layout_build_menu() -> void: # Structures the player has not researched are hidden outright (#21), and # what remains is repacked so there are no gaps where they used to be. # This reverses the earlier decision to always list everything greyed # out; an option you cannot explain is apparently worse than one you # cannot see yet. var shown: Array = [] for entry in build_menu_entries: var visible: bool = GameState.is_building_unlocked(entry[0]) entry[1].visible = visible entry[2].visible = visible entry[3].visible = visible if visible: shown.append(entry) for i in range(shown.size()): var row: int = i / 2 # Centred when it is the only one on its row, rather than hanging in # the left column looking like something is missing beside it. var alone: bool = (i == shown.size() - 1) and (i % 2 == 0) var cx: float = 0.0 if alone else (-MENU_COL_X if i % 2 == 0 else MENU_COL_X) var top: float = MENU_ROW_TOPS[mini(row, MENU_ROW_TOPS.size() - 1)] _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) _place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, MENU_LABEL_HALF, MENU_LABEL_H) _place_entry(shown[i][3], cx, top + MENU_COST_OFFSET, MENU_COST_HALF, MENU_COST_H) func _refresh_build_status() -> void: _layout_build_menu() # Researched but not usable right here is still shown, greyed: an # extractor needs a planet under the fleet, which is a "not now" rather # than a "not yet". _refresh_build_option_states() # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. var planet: Node2D = _planet_under_fleet() if planet == null: build_status.text = "" elif not GameState.is_planet_conquered(planet.planet_id): build_status.text = "Over %s - capture it before building an extractor" % planet.planet_name else: build_status.text = "Over %s - an extractor can be built here" % planet.planet_name func _refresh_build_option_states() -> void: for entry in build_menu_entries: var definition: Resource = entry[4] var enabled: bool = GameState.is_building_unlocked(definition.definition_id) if definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() enabled = enabled and planet != null and GameState.is_planet_conquered(planet.planet_id) entry[1].enabled = enabled entry[1].queue_redraw() func _close_build_menu() -> void: # The popup is a child panel of this menu; leaving it "open" while its # parent is hidden is what blocked every button in the game. info_popup.visible = false pending_build_type = "" build_menu.visible = false func cost_of(type: String) -> int: var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) return int(definition.cost) if definition != null else 0 func _show_building_info(type: String) -> void: var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) if definition == null: return var cost: int = int(definition.cost) var lines: Array = [definition.description] var can_build: bool = true # Locked structures still list themselves, greyed, so the player can see # what exists and what research would open it up. if not GameState.is_building_unlocked(type): lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type)) can_build = false # Anything that would stop this particular placement is spelled out in # the same popup, rather than silently failing after the press. if can_build and definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() if planet == null: lines.append("No planet in reach - fly over one.") can_build = false elif not GameState.is_planet_conquered(planet.planet_id): lines.append("%s is not captured yet - take all its capture points." % planet.planet_name) can_build = false elif GameState.has_extractor_at(planet.planet_id): lines.append("%s already has an extractor." % planet.planet_name) can_build = false else: lines.append("Will mine %s from %s." % [GameState.resource_name(planet.resource_type), planet.planet_name]) elif can_build and definition.unique and GameState.has_building_of_type(type): lines.append("WARNING: only one may exist - this destroys your current one.") if can_build and not GameState.can_afford(cost): lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks]) can_build = false info_title.text = definition.display_name info_message.text = "\n\n".join(lines) info_cost.amount = cost info_build_button.visible = can_build pending_build_type = type if can_build else "" info_popup.visible = true func _close_info() -> void: info_popup.visible = false pending_build_type = "" func _confirm_build() -> void: var type: String = pending_build_type if type == "": return pending_build_type = "" info_popup.visible = false # Everything is re-checked here rather than trusted from when the popup # opened: the fleet keeps flying while it is up, so it can drift off the # planet an extractor was going to mine. if not GameState.is_building_unlocked(type): return var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) if definition == null: return if definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() if planet == null or GameState.has_extractor_at(planet.planet_id): build_status.text = "No planet in reach any more" return if not GameState.is_planet_conquered(planet.planet_id): build_status.text = "%s is not captured yet" % planet.planet_name return if not GameState.spend(definition.cost): build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks] return GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id) build_menu.visible = false return if definition.unique and GameState.has_building_of_type(type): if not GameState.can_afford(definition.cost): build_status.text = "Not enough rocks (need %d, have %d)" % [ definition.cost, GameState.rocks ] return # Only now is the old one destroyed - never before the new one is # known to be affordable, or the player would be left with none. GameState.remove_buildings_of_type(type) building_field.rebuild_all() _try_place(type, cost_of(type)) func _try_place(type: String, cost: int) -> void: if not GameState.spend(cost): build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.rocks] return building_field.place(type, fleet.get_centroid()) build_menu.visible = false # --- research (the mothership's skill tree) -------------------------------- func _on_skill_node_picked(id: String) -> void: _refresh_skill_menu(id) func _refresh_skill_menu(id: String = "") -> void: if id == "": id = skill_tree.selected_id if id == "": # Nothing chosen yet: point the player at the tree rather than # leaving the panel blank, which reads as broken. skill_detail.text = "Select a node to see what it unlocks." skill_cost.amount = 0 skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size() skill_unlock_button.visible = false return var node: Dictionary = GameState.skill_node(id) if node.is_empty(): return skill_detail.text = "%s\n%s" % [node.name, node.desc] skill_cost.amount = int(node.cost) if GameState.is_unlocked(id): skill_status.text = "Researched" skill_unlock_button.visible = false elif not GameState.prerequisite_met(id): skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name skill_unlock_button.visible = false elif not GameState.can_afford(int(node.cost)): skill_status.text = "Not enough rocks - you have %d" % GameState.rocks skill_unlock_button.visible = false else: skill_status.text = "" skill_unlock_button.visible = true func _on_unlock_pressed() -> void: var id: String = skill_tree.selected_id if id == "": return if not GameState.unlock(id): # Re-check rather than trust the button: rocks can be spent elsewhere # while this menu is open, since haulers keep delivering. _refresh_skill_menu(id) return # A capacity upgrade has to reach the fleet that is already flying, not # just ones rebuilt later. if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0: fleet.apply_formation() skill_tree.queue_redraw() _refresh_skill_menu(id) func _say(message: String) -> void: craft_message = message craft_message_until = Time.get_ticks_msec() + 3000 _refresh_craft_status() func _format_costs(costs: Dictionary) -> String: var parts: PackedStringArray = PackedStringArray() for resource_type in GameState.resource_types(): var amount: int = int(costs.get(resource_type, 0)) if amount > 0: parts.append("%d %s" % [amount, GameState.resource_name(resource_type).to_lower()]) return " · ".join(parts) if not parts.is_empty() else "free" func _format_missing_costs(costs: Dictionary) -> String: var parts: PackedStringArray = PackedStringArray() for resource_type in GameState.resource_types(): var amount: int = int(costs.get(resource_type, 0)) if amount > 0: parts.append("%d more %s" % [amount, GameState.resource_name(resource_type).to_lower()]) return " · ".join(parts) func _refresh_craft_status() -> void: # Prices live with the buttons they describe. The top of the screen is # intentionally reserved for the shared resource bar and a short action # message; repeating fleet, queue and transport counts here buried the UI. craft_status.text = "" var definitions: Array = VESSEL_DEFINITIONS.fleet() for i in range(mini(definitions.size(), craft_vessel_buttons.size())): var definition: Resource = definitions[i] var button: IconButton = craft_vessel_buttons[i] craft_vessel_labels[i].text = "%s\n%s" % [definition.display_name, _format_costs(GameState.product_costs(definition.definition_id))] button.icon = definition.icon button.glow_color = definition.glow_color button.enabled = GameState.vessel_is_unlocked(definition.definition_id) button.queue_redraw() _refresh_queue() if craft_message != "": if Time.get_ticks_msec() < craft_message_until: craft_status.text = "%s\n%s" % [craft_message, craft_status.text] else: craft_message = "" func _on_craft_interceptor() -> void: _on_craft_vessel(GameState.VESSEL_INTERCEPTOR) func _on_craft_vessel(vessel: String) -> void: if craft_building_id == 0: _say("No crafter selected - reopen this panel from the building") return var definition: Resource = VESSEL_DEFINITIONS.get_definition(vessel) if definition == null: return if not GameState.queue_order(craft_building_id, vessel): if not GameState.vessel_is_unlocked(vessel): _say("%s must be unlocked at a ship lab first" % definition.display_name) return _report_order(vessel) func _report_order(product: String) -> void: var missing: Dictionary = GameState.outstanding_costs(craft_building_id) if not missing.is_empty(): _say("%s queued - deliver %s here" % [GameState.vessel_name(product), _format_missing_costs(missing)]) else: _say("%s ordered - building now" % GameState.vessel_name(product)) # --- ship lab (#5) -------------------------------------------------------- func _lab_options() -> Array: # The shield VESSEL unlock is offered on the shield page as an extra # entry, because unlocking the type and upgrading it belong in the same # place - and its upgrades are pointless until it exists. var options: Array = [] var definition: Resource = VESSEL_DEFINITIONS.get_definition(lab_vessel) if definition == null: return options if not GameState.vessel_is_unlocked(lab_vessel): options.append({"unlock": true, "id": definition.unlock_id, "name": definition.unlock_name, "desc": definition.unlock_description, "costs": GameState.research_costs(definition.unlock_id), "icon": definition.unlock_icon if definition.unlock_icon >= 0 else definition.icon}) return options for u in GameState.upgrades_for(lab_vessel): options.append({"unlock": false, "id": u.id, "name": u.name, "desc": u.desc}) return options func _on_lab_vessel(index: int) -> void: var definitions: Array = VESSEL_DEFINITIONS.all() if index < 0 or index >= definitions.size(): return lab_vessel = definitions[index].definition_id lab_choice = 0 _refresh_lab() func _on_lab_upgrade(index: int) -> void: if index < 0 or index >= _lab_options().size(): return lab_choice = index lab_status.text = "" _refresh_lab() func _on_lab_buy() -> void: var options: Array = _lab_options() if options.is_empty(): return var choice: Dictionary = options[lab_choice] var id: String = String(choice.id) if not GameState.queue_lab_research(lab_building_id, id): lab_status.text = "Research is already queued or complete" return lab_status.text = "%s queued - deliver %s to this lab" % [choice.name, _format_costs(GameState.research_costs(id))] _refresh_lab() func _refresh_lab() -> void: var definitions: Array = VESSEL_DEFINITIONS.all() for i in range(mini(lab_vessel_buttons.size(), definitions.size())): var definition: Resource = definitions[i] var this_vessel: String = definition.definition_id lab_vessel_buttons[i].enabled = true lab_vessel_buttons[i].icon = definition.icon lab_vessel_buttons[i].glow_color = definition.glow_color lab_vessel_labels[i].text = definition.display_name lab_vessel_buttons[i].highlighted = this_vessel == lab_vessel lab_vessel_buttons[i].queue_redraw() lab_vessel_label.text = "%s research" % GameState.vessel_name(lab_vessel) var options: Array = _lab_options() for i in range(lab_upgrade_buttons.size()): var visible: bool = i < options.size() lab_upgrade_buttons[i].visible = visible lab_upgrade_labels[i].visible = visible if not visible: continue var option: Dictionary = options[i] var price_text: String = _format_costs(option.costs if bool(option.unlock) \ else GameState.research_costs(option.id)) if not bool(option.unlock) and GameState.upgrade_maxed(option.id): price_text = "MAX" var level_note: String = "" if bool(option.unlock) else " %d/%d" % [ GameState.upgrade_level(option.id), int(GameState.upgrade_def(option.id).max_level)] lab_upgrade_labels[i].text = "%s%s\n%s" % [option.name, level_note, price_text] lab_upgrade_buttons[i].icon = _upgrade_icon(option) lab_upgrade_buttons[i].glow_color = _vessel_colour(lab_vessel) lab_upgrade_buttons[i].highlighted = i == lab_choice lab_upgrade_buttons[i].enabled = true lab_upgrade_buttons[i].queue_redraw() if options.is_empty(): lab_detail.text = "" lab_buy_button.enabled = false lab_buy_button.queue_redraw() return lab_choice = clampi(lab_choice, 0, options.size() - 1) var choice: Dictionary = options[lab_choice] lab_detail.text = "%s\n%s" % [choice.name, choice.desc] var queued: Dictionary = GameState.lab_research_order(lab_building_id) lab_buy_button.enabled = queued.is_empty() and (choice.unlock or not GameState.upgrade_maxed(choice.id)) if not queued.is_empty(): lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))] lab_buy_button.queue_redraw() func _upgrade_icon(option: Dictionary) -> IconButton.Icon: return int(option.get("icon", IconButton.Icon.SHIP_LAB)) func _on_craft_shield() -> void: _on_craft_vessel(GameState.VESSEL_SHIELD) # --- moving ore, in both directions, at any building (#24) ---------------- # Sentinel for the "All" buttons. The transfer primitives cap what they move # by what is actually there, so asking for more than could possibly exist is # how you say "everything" without the UI having to work out how much that # is first - and the answer cannot go stale between the read and the press. const ALL: int = 1 << 30 func _on_transfer_pressed() -> void: if current_building == null and current_extractor_index < 0: return transfer_building_id = current_building.building_id if current_building != null else 0 transfer_extractor_index = current_extractor_index for bar in resource_bars: bar.building_id = transfer_building_id transfer_menu.visible = true transfer_status.text = "" _refresh_transfer() func _on_transfer(to_building: bool, amount: int) -> void: _on_resource_transfer(GameState.RESOURCE_ORE, to_building, amount) func _on_resource_transfer(resource_type: String, to_building: bool, amount: int = ALL) -> void: if transfer_building_id == 0 and transfer_extractor_index < 0: transfer_status.text = "No resource store selected - reopen this panel from its action" return var moved: int if transfer_extractor_index >= 0: moved = GameState.transfer_resource_to_extractor(transfer_extractor_index, resource_type, amount) \ if to_building else GameState.transfer_resource_from_extractor(transfer_extractor_index, resource_type, amount) else: moved = GameState.transfer_resource_to_building(transfer_building_id, resource_type, amount) \ if to_building else GameState.transfer_resource_to_fleet(transfer_building_id, resource_type, amount) var resource_name: String = GameState.resource_name(resource_type).to_lower() # Nothing moving is the common case at an empty end, and a silent no-op # is the dead-button problem this panel exists to remove. Say which side # was empty rather than just refusing. if moved <= 0: if to_building: transfer_status.text = "The fleet is carrying nothing to unload (no %s)" % resource_name else: transfer_status.text = "%s has no %s to load" % [_transfer_place_name().capitalize(), resource_name] elif to_building: transfer_status.text = "Moved %d %s into %s" % [moved, resource_name, _transfer_place_name()] else: transfer_status.text = "Loaded %d %s into the fleet" % [moved, resource_name] _refresh_transfer() func _on_gas_transfer(to_building: bool) -> void: _on_resource_transfer(GameState.RESOURCE_GAS, to_building) func _transfer_place_name() -> String: if transfer_extractor_index >= 0: return "the extractor" var b: Dictionary = GameState.find_building(transfer_building_id) if b.is_empty(): return "this building" if b.type == GameState.BUILDING_MOTHERSHIP: # Worth naming as the bank rather than as a structure: this is the # one building where unloading makes the ore spendable everywhere. return "the bank" return "this building" func _refresh_transfer() -> void: if transfer_extractor_index >= 0: transfer_list.set_extractor_destination(transfer_extractor_index, "Planet extractor") return var building: Dictionary = GameState.find_building(transfer_building_id) var heading: String = "This building" if not building.is_empty() and building.type == GameState.BUILDING_MOTHERSHIP: heading = "Mothership bank" transfer_list.set_destination(transfer_building_id, heading) func _refresh_queue() -> void: # The queue down the right of the craft panel: one slot per pending # vessel, the first being the one actually under construction. Tapping a # slot cancels it (#23). var orders: Array = GameState.order_list(craft_building_id) for i in range(queue_slots.size()): var slot: IconButton = queue_slots[i] slot.visible = i < orders.size() if not slot.visible: continue var order: Dictionary = orders[i] var definition: Resource = VESSEL_DEFINITIONS.get_definition(String(order.product)) slot.icon = definition.icon slot.glow_color = _vessel_colour(String(order.product)) # Only the one under construction reads as active; the rest are # waiting, and greyed says so without needing a caption each. slot.enabled = bool(order.started) slot.queue_redraw() if orders.is_empty(): queue_detail.text = "Nothing queued" return var head: Dictionary = orders[0] if bool(head.started): queue_detail.text = "Building %s %d%%\nTap to cancel" % [ GameState.vessel_name(String(head.product)), int(round(float(head.progress) * 100.0)), ] else: queue_detail.text = "%s waiting for resources\nTap to cancel" % \ GameState.vessel_name(String(head.product)) func _vessel_colour(product: String) -> Color: var definition: Resource = VESSEL_DEFINITIONS.get_definition(product) return definition.glow_color if definition != null else Color(0.25, 0.85, 1.0) func _on_queue_slot(index: int) -> void: var orders: Array = GameState.order_list(craft_building_id) if index >= orders.size(): return var vessel: String = GameState.vessel_name(String(orders[index].product)) var refunded: bool = bool(orders[index].started) if not GameState.cancel_order(craft_building_id, index): return _say("%s cancelled%s" % [vessel, " - resources refunded here" if refunded else ""]) _refresh_queue() # --- hangar (#23) --------------------------------------------------------- func _on_hangar_pressed() -> void: hangar_status.text = "" hangar_menu.visible = true _refresh_hangar() func _on_hangar_crew_type(index: int) -> void: var type: String = _hangar_types()[index] if fleet.ships.size() >= GameState.max_fleet_size(): hangar_status.text = "Fleet is full (%d/%d) - research capacity at the mothership" % [ fleet.ships.size(), GameState.max_fleet_size() ] return var stored_index: int = GameState.stored_vessels.find(type) if stored_index < 0: hangar_status.text = "No %s available" % GameState.vessel_name(type) return if not fleet.add_ship(GameState.take_stored_vessel(stored_index)): hangar_status.text = "Could not crew that vessel" return hangar_status.text = "%s joined the fleet" % GameState.vessel_name(type) _refresh_hangar() func _on_hangar_store_type(index: int) -> void: var type: String = _hangar_types()[index] var ship_index: int = -1 for i in range(fleet.ships.size()): if String(fleet.ships[i].vessel_type) == type: ship_index = i break if ship_index < 0: hangar_status.text = "No %s in the fleet" % GameState.vessel_name(type) return var stored: String = fleet.store_ship(ship_index) if stored == "": hangar_status.text = "Keep one ship deployed" return GameState.stored_vessels.append(stored) hangar_status.text = "%s stored in the hangar" % GameState.vessel_name(stored) _refresh_hangar() func _hangar_types() -> Array: var types: Array = [] for definition in VESSEL_DEFINITIONS.fleet(): types.append(definition.definition_id) return types func _refresh_hangar() -> void: var types: Array = _hangar_types() for i in range(types.size()): var type: String = types[i] var definition: Resource = VESSEL_DEFINITIONS.get_definition(type) var available: int = GameState.stored_vessels.count(type) var deployed: int = 0 for ship in fleet.ships: if is_instance_valid(ship) and String(ship.vessel_type) == type: deployed += 1 hangar_available_labels[i].text = "%s\n%d available" % [definition.display_name, available] hangar_fleet_labels[i].text = "%s\n%d in fleet" % [definition.display_name, deployed] hangar_available_slots[i].icon = definition.icon hangar_fleet_slots[i].icon = definition.icon hangar_available_slots[i].glow_color = definition.glow_color hangar_fleet_slots[i].glow_color = definition.glow_color hangar_available_slots[i].enabled = available > 0 and fleet.ships.size() < GameState.max_fleet_size() hangar_fleet_slots[i].enabled = deployed > 0 and fleet.ships.size() > 1 hangar_available_slots[i].queue_redraw() hangar_fleet_slots[i].queue_redraw() func _on_formation_changed() -> void: fleet.apply_formation() func _on_reset_formation() -> void: GameState.reset_formation() fleet.apply_formation() formation_editor.queue_redraw()