extends Node2D # 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 at a crafter, configure formation at a # command centre). The enter-atmosphere prompt uses the same slot, and # PlanetAtmosphereController suppresses itself while a building prompt # is showing so the two can never fight over it. # - 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 build_menu: Control = get_node("../UI/BuildMenu") @onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton") @onready var build_crafter_button: IconButton = get_node("../UI/BuildMenu/CrafterButton") @onready var build_command_button: IconButton = get_node("../UI/BuildMenu/CommandButton") @onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton") @onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") @onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton") @onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton") @onready var build_lab_button: IconButton = get_node("../UI/BuildMenu/ShipLabButton") @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"), ] @onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel") @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/AvailableTransporter"), ] @onready var hangar_available_labels: Array = [ get_node("../UI/HangarMenu/AvailableInterceptorLabel"), get_node("../UI/HangarMenu/AvailableShieldLabel"), get_node("../UI/HangarMenu/AvailableTransporterLabel"), ] @onready var hangar_fleet_slots: Array = [ get_node("../UI/HangarMenu/FleetInterceptor"), get_node("../UI/HangarMenu/FleetShield"), get_node("../UI/HangarMenu/FleetTransporter"), ] @onready var hangar_fleet_labels: Array = [ get_node("../UI/HangarMenu/FleetInterceptorLabel"), get_node("../UI/HangarMenu/FleetShieldLabel"), get_node("../UI/HangarMenu/FleetTransporterLabel"), ] @onready var build_menu_entries: Array = [ [GameState.BUILDING_MOTHERSHIP, build_mothership_button, get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")], [GameState.BUILDING_SHIP_CRAFTER, build_crafter_button, get_node("../UI/BuildMenu/CrafterLabel"), get_node("../UI/BuildMenu/CrafterCost")], [GameState.BUILDING_COMMAND_CENTER, build_command_button, get_node("../UI/BuildMenu/CommandLabel"), get_node("../UI/BuildMenu/CommandCost")], [GameState.BUILDING_EXTRACTOR, build_extractor_button, get_node("../UI/BuildMenu/ExtractorLabel"), get_node("../UI/BuildMenu/ExtractorCost")], [GameState.BUILDING_TRANSPORT_HUB, build_hub_button, get_node("../UI/BuildMenu/TransportHubLabel"), get_node("../UI/BuildMenu/TransportHubCost")], [GameState.BUILDING_SHIP_LAB, build_lab_button, get_node("../UI/BuildMenu/ShipLabLabel"), get_node("../UI/BuildMenu/ShipLabCost")], ] # 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 @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_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton") @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_transporter_label: Label = get_node("../UI/CraftMenu/TransporterLabel") @onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel") # --- 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 button that works at every building and in both # directions, so moving ore is never buried under an unrelated menu. @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 to_building_label: Label = get_node("../UI/TransferMenu/ToBuildingLabel") @onready var to_building_batch_button: IconButton = get_node("../UI/TransferMenu/ToBuildingBatchButton") @onready var to_building_batch_label: Label = get_node("../UI/TransferMenu/ToBuildingBatchLabel") @onready var to_building_all_button: IconButton = get_node("../UI/TransferMenu/ToBuildingAllButton") @onready var to_fleet_label: Label = get_node("../UI/TransferMenu/ToFleetLabel") @onready var to_fleet_batch_button: IconButton = get_node("../UI/TransferMenu/ToFleetBatchButton") @onready var to_fleet_batch_label: Label = get_node("../UI/TransferMenu/ToFleetBatchLabel") @onready var to_fleet_all_button: IconButton = get_node("../UI/TransferMenu/ToFleetAllButton") # 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"), get_node("../UI/TransferMenu/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") # What each option is and does, shown before anything is spent. Picking a # structure is a real commitment - it costs ore, and a mothership placement # can destroy the one you already have - so no option builds straight from # the menu; each one explains itself first and waits for a confirmation. # # The bodies are kept short on purpose: at the UI's font size the popup holds # roughly eight lines, and a placement condition or a price warning is # appended below them and needs some of those lines. const BUILDING_INFO: Dictionary = { GameState.BUILDING_MOTHERSHIP: { "title": "Mothership", "body": "Fleet home port. A wiped-out fleet rebuilds here, and extractor haulers deliver ore here.", }, GameState.BUILDING_SHIP_CRAFTER: { "title": "Ship Crafter", "body": "Fly the fleet onto it, then use the action button to build extra vessels.", }, GameState.BUILDING_COMMAND_CENTER: { "title": "Command Center", "body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.", }, GameState.BUILDING_SHIP_LAB: { "title": "Ship Lab", "body": "Fly the fleet onto it to upgrade each vessel type, and to unlock the shield vessel.", }, GameState.BUILDING_TRANSPORT_HUB: { "title": "Transport Hub", "body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.", }, GameState.BUILDING_EXTRACTOR: { "title": "Extractor", "body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.", }, } # 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 current_building: Node2D = null var pending_building: 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) build_mothership_button.pressed.connect(_on_place_mothership) build_crafter_button.pressed.connect(_on_place_crafter) build_command_button.pressed.connect(_on_place_command_center) build_extractor_button.pressed.connect(_on_place_extractor) build_hub_button.pressed.connect(_on_place_transport_hub) build_lab_button.pressed.connect(_on_place_ship_lab) craft_shield_button.pressed.connect(_on_craft_shield) 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) craft_interceptor_button.pressed.connect(_on_craft_interceptor) craft_transporter_button.pressed.connect(_on_craft_transporter) 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) to_building_batch_button.pressed.connect(_on_transfer.bind(true, GameState.CARGO_LOAD_BATCH)) to_building_all_button.pressed.connect(_on_transfer.bind(true, ALL)) to_fleet_batch_button.pressed.connect(_on_transfer.bind(false, GameState.CARGO_LOAD_BATCH)) to_fleet_all_button.pressed.connect(_on_transfer.bind(false, ALL)) 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 transfer_button.visible = false hangar_button.visible = false hangar_menu.visible = false _layout_context_buttons() # True while a building prompt or panel owns the shared action slot, so the # enter-atmosphere prompt knows to stay out of the way. func is_showing_building_prompt() -> bool: return current_building != null or 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. build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) build_extractor_button.queue_redraw() 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 action_button.visible = false building_action_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() if detected != pending_building: pending_building = detected pending_time = 0.0 else: pending_time += delta if pending_time < prompt_debounce_time or pending_building == current_building: return current_building = pending_building context_actions_expanded = false # The hangar is where finished vessels are delivered (#23), so it belongs # at the mothership and nowhere else. hangar_button.visible = current_building != null \ and current_building.building_type == GameState.BUILDING_MOTHERSHIP # Every building can hold ore, so the transfer button rides alongside the # action button rather than being one of the things the action button # turns into - the two are independent choices at the same structure. transfer_button.visible = false building_action_button.visible = false action_button.visible = current_building != null if current_building != null: var actions: Array = _context_action_buttons() building_action_button.icon = _action_icon_for(current_building.building_type) building_action_button.queue_redraw() action_button.icon = building_action_button.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: if current_building == null: return [] var actions: Array = [transfer_button] if current_building.building_type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, GameState.BUILDING_SHIP_LAB, GameState.BUILDING_COMMAND_CENTER]: actions.push_front(building_action_button) if current_building.building_type == GameState.BUILDING_MOTHERSHIP: actions.append(hangar_button) return actions func _on_context_action_pressed() -> void: if current_building == null: return var actions: Array = _context_action_buttons() if actions.size() == 1: if actions[0] == transfer_button: _on_transfer_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 _action_icon_for(type: String) -> int: match type: GameState.BUILDING_SHIP_CRAFTER: return IconButton.Icon.SHIP GameState.BUILDING_TRANSPORT_HUB: return IconButton.Icon.TRANSPORT_HUB GameState.BUILDING_SHIP_LAB: return IconButton.Icon.SHIP_LAB GameState.BUILDING_MOTHERSHIP: return IconButton.Icon.MOTHERSHIP _: return IconButton.Icon.COMMAND_CENTER 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 if current_building.building_type == GameState.BUILDING_MOTHERSHIP: skill_menu.visible = true skill_tree.reset_scroll() _refresh_skill_menu() elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: # 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 current_building.building_type == GameState.BUILDING_SHIP_LAB: lab_menu.visible = true _refresh_lab() elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: 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, -10.0, 280.0] const MENU_COL_X: float = 140.0 const MENU_BUTTON_HALF: float = 64.0 const MENU_BUTTON_H: float = 128.0 const MENU_LABEL_HALF: float = 150.0 const MENU_LABEL_H: float = 66.0 const MENU_COST_HALF: float = 60.0 const MENU_COST_H: float = 52.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 + 134.0, MENU_LABEL_HALF, MENU_LABEL_H) _place_entry(shown[i][3], cx, top + 204.0, 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". build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. if _planet_under_fleet() != null: build_status.text = "Over %s - an extractor can be built here" % _planet_under_fleet().planet_name else: build_status.text = "" func _on_place_mothership() -> void: _show_building_info(GameState.BUILDING_MOTHERSHIP) func _on_place_crafter() -> void: _show_building_info(GameState.BUILDING_SHIP_CRAFTER) func _on_place_command_center() -> void: _show_building_info(GameState.BUILDING_COMMAND_CENTER) 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 _on_place_ship_lab() -> void: _show_building_info(GameState.BUILDING_SHIP_LAB) func _on_place_transport_hub() -> void: _show_building_info(GameState.BUILDING_TRANSPORT_HUB) func _on_place_extractor() -> void: # The option stays pressable while greyed, so this is also how a # not-over-a-planet extractor explains itself. _show_building_info(GameState.BUILDING_EXTRACTOR) func cost_of(type: String) -> int: match type: GameState.BUILDING_TRANSPORT_HUB: return GameState.TRANSPORT_HUB_COST GameState.BUILDING_SHIP_LAB: return GameState.SHIP_LAB_COST GameState.BUILDING_MOTHERSHIP: return GameState.MOTHERSHIP_COST GameState.BUILDING_SHIP_CRAFTER: return GameState.SHIP_CRAFTER_COST GameState.BUILDING_COMMAND_CENTER: return GameState.COMMAND_CENTER_COST _: return GameState.EXTRACTOR_COST func _show_building_info(type: String) -> void: var info: Dictionary = BUILDING_INFO[type] var cost: int = cost_of(type) var lines: Array = [info.body] 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 type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null: lines.append("No planet in reach - fly over one.") can_build = false elif GameState.has_extractor_at(planet.planet_name): lines.append("%s already has an extractor." % planet.planet_name) can_build = false else: lines.append("Will mine %s." % planet.planet_name) elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): 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 = info.title 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 if type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null or GameState.has_extractor_at(planet.planet_name): build_status.text = "No planet in reach any more" return if not GameState.spend(GameState.EXTRACTOR_COST): build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks] return GameState.add_extractor(planet.planet_name, planet.position) build_menu.visible = false return if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): if not GameState.can_afford(GameState.MOTHERSHIP_COST): build_status.text = "Not enough rocks (need %d, have %d)" % [ GameState.MOTHERSHIP_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(GameState.BUILDING_MOTHERSHIP) 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 _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_interceptor_label.text = "Interceptor\n%d ore" % GameState.SHIP_COST craft_transporter_label.text = "Transporter\n%d ore" % GameState.TRANSPORTER_COST craft_shield_label.text = "Shield Vessel\n%d ore" % GameState.SHIELD_COST craft_status.text = "" # Nowhere to station one means the order would be refused, so say so # rather than letting the button look available and do nothing. craft_transporter_button.enabled = GameState.has_transport_hub() \ and GameState.can_station_transporter() craft_transporter_button.queue_redraw() craft_shield_button.enabled = GameState.shield_vessel_unlocked() craft_shield_button.queue_redraw() # Fleet capacity no longer gates ORDERING (#23). A finished vessel waits # at the mothership until the player crews it, so a full fleet is a # reason not to collect one yet - not a reason the crafter cannot build. # This is also what stopped the crafter being inert on a fresh save, # where the fleet starts at exactly its cap. craft_interceptor_button.enabled = true craft_interceptor_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: if craft_building_id == 0: _say("No crafter selected - reopen this panel from the building") return # No affordability check: an order the crafter cannot pay for yet is # QUEUED, and completes by itself once ore is delivered here (#16). But # queueing silently looks identical to a dead button, so it says which # happened and what the crafter is still short of. GameState.queue_order(craft_building_id) _report_order(GameState.PRODUCT_SHIP) func _report_order(product: String) -> void: var short: int = GameState.outstanding_cost(craft_building_id) if short > 0: _say("%s queued - %d more ore must be delivered here" % [ GameState.VESSEL_NAMES.get(product, product), short ]) else: _say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product)) func _on_craft_transporter() -> void: if craft_building_id == 0: _say("No crafter selected - reopen this panel from the building") return if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER): # Two different refusals, and the player needs to know WHICH. if not GameState.has_transport_hub(): _say("Build a Transport Hub first - a transporter needs somewhere to be stationed") else: _say("All %d berths are taken - build another hub, or research Freight Yards" % GameState.transport_capacity()) return _refresh_craft_status() # --- 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 = [] if lab_vessel == GameState.VESSEL_SHIELD and not GameState.shield_vessel_unlocked(): options.append({"unlock": true, "name": "Unlock Shield Vessel", "desc": "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up.", "cost": GameState.UNLOCK_SHIELD_COST}) 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: lab_vessel = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER][index] 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] if choice.unlock: if GameState.unlock_shield_vessel(): lab_status.text = "Shield vessel unlocked - build one at a ship crafter" else: lab_status.text = "Not enough ore: needs %d, you have %d" % [ GameState.UNLOCK_SHIELD_COST, GameState.rocks ] lab_choice = 0 _refresh_lab() return var id: String = choice.id if GameState.upgrade_maxed(id): lab_status.text = "%s is already fully researched" % choice.name return var cost: int = GameState.upgrade_cost(id) if not GameState.buy_upgrade(id): lab_status.text = "Not enough ore: needs %d, you have %d" % [cost, GameState.rocks] return lab_status.text = "%s is now level %d" % [choice.name, GameState.upgrade_level(id)] _refresh_lab() func _refresh_lab() -> void: for i in range(lab_vessel_buttons.size()): var this_vessel: String = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER][i] lab_vessel_buttons[i].enabled = true lab_vessel_buttons[i].glow_color = Color(0.45, 0.9, 0.55) if this_vessel == lab_vessel \ else _vessel_colour(this_vessel) lab_vessel_buttons[i].queue_redraw() lab_vessel_label.text = "%s research" % GameState.VESSEL_NAMES.get(lab_vessel, 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 = "%d ore" % (GameState.UNLOCK_SHIELD_COST if bool(option.unlock) else GameState.upgrade_cost(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 = Color(1.0, 0.85, 0.4) if i == lab_choice else _vessel_colour(lab_vessel) 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] if choice.unlock: lab_buy_button.enabled = GameState.can_afford(GameState.UNLOCK_SHIELD_COST) else: lab_buy_button.enabled = GameState.can_buy_upgrade(choice.id) lab_buy_button.queue_redraw() func _upgrade_icon(option: Dictionary) -> IconButton.Icon: if bool(option.unlock): return IconButton.Icon.UPGRADE_UNLOCK_SHIELD match String(option.id): "int_damage": return IconButton.Icon.UPGRADE_DAMAGE "int_rate": return IconButton.Icon.UPGRADE_RATE "int_range": return IconButton.Icon.UPGRADE_RANGE "int_speed", "shd_speed": return IconButton.Icon.UPGRADE_THRUSTERS "shd_hits": return IconButton.Icon.UPGRADE_SHIELD_HITS "shd_recharge": return IconButton.Icon.UPGRADE_SHIELD_RECHARGE "trn_capacity": return IconButton.Icon.UPGRADE_CARGO "trn_speed": return IconButton.Icon.UPGRADE_ENGINES _: return IconButton.Icon.SHIP_LAB func _on_craft_shield() -> void: if craft_building_id == 0: _say("No crafter selected - reopen this panel from the building") return if not GameState.shield_vessel_unlocked(): _say("Shield vessels must be unlocked at a ship lab first") return GameState.queue_order(craft_building_id, GameState.VESSEL_SHIELD) _report_order(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: return transfer_building_id = current_building.building_id transfer_menu.visible = true transfer_status.text = "" _refresh_transfer() func _on_transfer(to_building: bool, amount: int) -> void: if transfer_building_id == 0: transfer_status.text = "No building selected - reopen this panel from the building" return var moved: int = 0 if to_building: moved = GameState.transfer_to_building(transfer_building_id, amount) else: moved = GameState.transfer_to_fleet(transfer_building_id, amount) # 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" else: transfer_status.text = "%s has no ore to load" % _transfer_place_name() elif to_building: transfer_status.text = "Moved %d ore into %s" % [moved, _transfer_place_name()] else: transfer_status.text = "Loaded %d ore into the fleet" % moved _refresh_transfer() func _transfer_place_name() -> String: 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: for bar in resource_bars: bar.building_id = transfer_building_id var hold: int = GameState.fleet_cargo var here: int = GameState.building_holdings(transfer_building_id) var batch: int = GameState.CARGO_LOAD_BATCH to_building_label.text = "Unload into %s" % _transfer_place_name() to_fleet_label.text = "Load from %s" % _transfer_place_name() # The batch buttons say what they would actually move, not the nominal # batch size: offering "25" when the hold has 4 in it is a promise the # press cannot keep. to_building_batch_label.text = "%d" % mini(batch, hold) to_fleet_batch_label.text = "%d" % mini(batch, here) to_building_batch_button.enabled = hold > 0 to_building_all_button.enabled = hold > 0 to_fleet_batch_button.enabled = here > 0 to_fleet_all_button.enabled = here > 0 for button in [to_building_batch_button, to_building_all_button, to_fleet_batch_button, to_fleet_all_button]: button.queue_redraw() 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] slot.icon = IconButton.Icon.TRANSPORT_HUB if order.product == GameState.PRODUCT_TRANSPORTER \ else IconButton.Icon.SHIP 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_NAMES.get(head.product, head.product), int(round(float(head.progress) * 100.0)), ] else: queue_detail.text = "%s waiting for ore\nTap to cancel" % \ GameState.VESSEL_NAMES.get(head.product, head.product) func _vessel_colour(product: String) -> Color: match product: GameState.VESSEL_SHIELD: return Color(0.45, 0.8, 1.0) GameState.PRODUCT_TRANSPORTER: return Color(0.95, 0.55, 0.85) _: return 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_NAMES.get(orders[index].product, 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, " - ore 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_NAMES.get(type, 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_NAMES.get(type, 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_NAMES.get(type, 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_NAMES.get(stored, stored) _refresh_hangar() func _hangar_types() -> Array: return [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER] func _refresh_hangar() -> void: var types: Array = _hangar_types() for i in range(types.size()): var type: String = types[i] 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" % [GameState.VESSEL_NAMES.get(type, type), available] hangar_fleet_labels[i].text = "%s\n%d in fleet" % [GameState.VESSEL_NAMES.get(type, type), deployed] 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()