extends Node2D const BUILDING_DEFINITIONS = preload("res://buildings/BuildingDefinitions.gd") const VESSEL_DEFINITIONS = preload("res://vessels/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"), get_node("../UI/LabMenu/VesselH"), ] @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"), get_node("../UI/LabMenu/VesselHLabel"), ] @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_available_title: Label = get_node("../UI/HangarMenu/AvailableTitle") @onready var hangar_fleet_title: Label = get_node("../UI/HangarMenu/FleetTitle") @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") # Typed Control, not PriceTag, and deliberately. PriceTag reads the GameState # autoload, and an `extends SceneTree` test script that so much as names a # class like that fails to compile - GameState is not an identifier in that # context - which then poisons the scene's copy of the same script. The nodes # are duck-typed everywhere for that reason; see tests/test_menu_price_unification.gd. @onready var info_cost: Control = 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"), ] # #103: the outpost's own panel. garrison_building_id is remembered for the # same reason craft_building_id is - _process() clears current_building while # a menu is open, so by the next frame the panel would not know which outpost # it belongs to and every button in it would bail out at its `== 0` guard. @onready var garrison_menu: Control = get_node("../UI/GarrisonMenu") @onready var garrison_panel: Control = get_node("../UI/GarrisonMenu/Panel") @onready var garrison_status: Label = get_node("../UI/GarrisonMenu/StatusLabel") @onready var garrison_close_button: IconButton = get_node("../UI/GarrisonMenu/CloseButton") var garrison_building_id: int = 0 # #106: the standing-order board. No building id is remembered because the # plan is global - there is one plan for the whole game, and a second # planning centre is another place to read it rather than a competing one. @onready var planning_menu: Control = get_node("../UI/PlanningMenu") @onready var planning_panel: Control = get_node("../UI/PlanningMenu/Panel") @onready var planning_status: Label = get_node("../UI/PlanningMenu/StatusLabel") @onready var planning_close_button: IconButton = get_node("../UI/PlanningMenu/CloseButton") @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: Control = get_node("../UI/SkillMenu/CostNumber") @onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel") @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 # Gap between the last resource row and the first row of HUD buttons. const HUD_COLUMN_MARGIN: float = 18.0 # Vertical gap between one row of HUD buttons and the next. const HUD_ROW_GAP: float = 16.0 # Seats the top-anchored HUD button columns below the resource readout instead # of at the fixed y the scene authored. # # ResourceReadout builds one row per resource type AT RUNTIME, so how far down # it reaches is not knowable when the scene is laid out - the scene's guess # was 154, and four resources actually reach 270, straight over the build # button sitting at a hardcoded 160. Every resource type added since made it # worse. Deriving the column top from the readout's real bottom makes the # overlap impossible rather than merely fixed for today's resource count. # # This TRANSLATES the block rather than re-flowing it, so the rows keep their # spacing relative to one another - BuildingActionButton and HangarButton # deliberately share a row, being mutually exclusive, and a flow layout would # pull them apart. func _seat_hud_columns() -> void: var readout: ResourceReadout = get_node_or_null("../UI/ResourceReadout") if readout == null: return # Both columns, not just the left one: the readout spans the full width # (the fleet hold column sits at x 356), so the second column of buttons # collides with it exactly as the first does. var buttons: Array = [ build_button, transfer_button, action_button, landing_button, building_action_button, hangar_button, ] var highest: float = INF for button in buttons: if button != null: highest = minf(highest, button.position.y) if is_inf(highest): return var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN - highest # Only ever pushed down, never pulled up: with few enough resources the # authored layout already clears the readout, and there is no reason to # drag the HUD away from where the scene deliberately put it. if shift <= 0.0: return for button in buttons: if button != null: button.position.y += shift func _ready() -> void: _seat_hud_columns() _layout_hangar() _build_craft_price_strips() _build_lab_price_tags() _layout_craft_menu() build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) garrison_close_button.pressed.connect(_close_garrison_menu) garrison_panel.changed.connect(_refresh_garrison) planning_close_button.pressed.connect(_close_planning_menu) planning_panel.changed.connect(_refresh_planning) _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_tree.node_picked.connect(_on_skill_node_picked) skill_tree.node_confirmed.connect(_on_skill_node_confirmed) 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 # #128: the prologue's "movement and nothing else" used to be applied by # _process on the first frame. A dialog now stops the tree, and the # prologue opens ON one - so that first frame never came and every action # button was left on screen behind the opening line. Applied here as well, # where it does not depend on the game running. if GameState.intro_active: _hide_all_actions() _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_tag: Control = 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_tag.show_price(GameState.building_costs(String(definition.definition_id))) build_menu_entries.append([definition.definition_id, button, label, cost_tag, 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) elif event is InputEventMouseButton: # #93: a click is not a touch. The project no longer emulates one # from the other, so without this branch the whole menu is inert # under a mouse - nothing selects, nothing scrolls, and there is # no other way to research anything on a desktop. skill_tree.handle_mouse_button(event) elif event is InputEventMouseMotion: skill_tree.handle_mouse_motion(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) elif event is InputEventMouseButton: # #121: a click is not a touch. Same routing gap as #93's # research tree - the panel is transparent to GUI input so the # raw handlers can see the drag, which means nothing turns a # click into one on the way. formation_editor.handle_mouse_button(event) elif event is InputEventMouseMotion: formation_editor.handle_mouse_motion(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() # #122: the prologue leaves the player movement and nothing else. Handled # at the top, before any of the context detection below, so nothing can # turn a button back on further down. if GameState.intro_active: _hide_all_actions() return # Build is permanently under the resource counter (see # _layout_context_buttons) - it is the one action button nothing else # ever hides, so the prologue taking it away is the only thing that has # to give it back. if not build_button.visible: build_button.visible = true 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 = _building_action_icon(current_building) 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. # # "Directly below it" is MEASURED from the build button rather than # written down as a y. This used to hardcode 324, which was the right # answer only while the build button sat at the 160 the scene authored - # once _seat_hud_columns() started moving the whole block down to clear # the resource readout, this ran on the next context change and dropped # the action row straight back on top of the build button. var row_top: float = build_button.position.y + build_button.size.y + HUD_ROW_GAP var row_height: float = build_button.size.y action_button.offset_top = row_top action_button.offset_bottom = row_top + row_height 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 = row_top button.offset_bottom = row_top + row_height 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") # A pause action's icon shows what the button will DO, not what the building # currently is: two bars while it runs, a play triangle while it is stopped. # Everything else uses the icon its definition names. func _building_action_icon(building: Node) -> int: if building.primary_action() == "pause": return IconButton.Icon.RESUME if GameState.is_building_paused(building.building_id) \ else IconButton.Icon.PAUSE return int(building.definition.action_icon) 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 == "pause": # No panel: the button IS the control. Toggling here rather than # opening something keeps a one-bit setting to a single tap. var paused: bool = GameState.toggle_building_paused(current_building.building_id) build_status.text = "%s %s" % [current_building.definition.display_name, "paused" if paused else "running"] building_action_button.icon = _building_action_icon(current_building) building_action_button.queue_redraw() return 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() elif action == "planning": planning_menu.visible = true _refresh_planning() elif action == "garrison": garrison_building_id = current_building.building_id garrison_panel.building_id = garrison_building_id garrison_menu.visible = true _refresh_garrison() 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. The menu is rebuilt from # whatever is currently researched (#21) rather than being a fixed # arrangement with holes punched in it. # # Two profiles, because the grid ran out of room. #54 already tightened the # rows to 223px to fit a fourth, explicitly to avoid a scrollable menu, and a # ninth structure (#106's planning centre) needs a FIFTH row that will not # fit at any spacing a button, a label and a cost can share. So past eight # entries the grid goes to three columns instead, which takes nine into three # rows - fewer than it uses today, not more. Narrower columns mean narrower # labels, so those get a taller box to wrap into. # The build menu's grid. # # #136: rows used to be a written-down list of tops, read with # `tops[mini(row, tops.size() - 1)]`. That clamp silently stacked whatever # would not fit - the gas burner drawn exactly on top of the transport hub. # # #144: and the band the rows live in was written down too, as a pair of # constants measured against a 1280-tall canvas. The header above it is # anchored to the TOP of the panel and the grid is anchored to its CENTRE, # so the gap between them is half the canvas height - which means the # constants were only ever right at one window shape. At 1080 tall the grid # started 4px ABOVE the status line and drew straight through it, which is # what the ticket reported. # # So nothing about the geometry is written down against a screen size any # more. The band is measured from the header that is actually there, the row # pitch closes up to fit it (never below a row's own height, which is what # makes overlapping impossible rather than unlikely), and the column count # rises until the rows fit - a short panel gets a wide grid instead of one # that runs off the bottom of it. const MENU_BUTTON_HALF: float = 64.0 const MENU_BUTTON_H: float = 100.0 const MENU_COST_HALF: float = 74.0 # #147: a price is one resource per line, so a row has to leave room for the # most any structure asks for - three, at the workshop. const MENU_COST_H: float = 86.0 const MENU_LABEL_OFFSET: float = 106.0 const MENU_LABEL_GAP: float = 4.0 # The widest a column is ever given, however much room there is: past this # the entries stop reading as a grid and start reading as a scattering. const MENU_NATURAL_SPACING: float = 280.0 # The narrowest a column may be squeezed to - a button plus a little air. const MENU_MIN_SPACING: float = MENU_BUTTON_HALF * 2.0 + 12.0 # A caption gets most of its column, not all of it. const MENU_LABEL_SHARE: float = 0.46 # Below this a caption is assumed to need two lines. const MENU_ONE_LINE_HALF: float = 120.0 const MENU_LABEL_H_ONE: float = 52.0 const MENU_LABEL_H_TWO: float = 68.0 const MENU_SIDE_MARGIN: float = 24.0 const MENU_HEADER_GAP: float = 16.0 const MENU_BOTTOM_MARGIN: float = 28.0 const MENU_MAX_COLUMNS: int = 6 # A centre-anchored grid living under a top-anchored header, which is the # shape both the build menu and the craft menu have (#144, #147). The gap # between the two depends on the panel's HEIGHT, so it can never be written # down as a constant - it has to be measured from the header that is # actually there. func grid_band(header_bottom: float, panel_height: float) -> Vector2: var half: float = panel_height * 0.5 if half <= 0.0: half = 640.0 return Vector2(header_bottom + MENU_HEADER_GAP - half, half - MENU_BOTTOM_MARGIN) # Where one row of such a grid starts. The pitch closes up to fit the band # and is floored at the row's own height, so rows can tighten but never land # on each other. func grid_row_top(row: int, rows: int, row_height: float, band: Vector2, natural_pitch: float) -> float: var pitch: float = natural_pitch if rows > 1: pitch = minf(pitch, (band.y - band.x - row_height) / float(rows - 1)) return band.x + maxf(pitch, row_height) * float(row) # The vertical band the grid may occupy, in the menu's own centre-anchored # space: (top, bottom). # # MEASURED, not written down (#144). The header is anchored to the top of the # panel and the grid to its centre, so the two only agree at one canvas # height - and the pair of constants this replaces happened to be the ones # for 1280. Reading the status line's own bottom edge and the panel's own # height makes the answer right at every window shape, including the one the # player is actually running. func menu_band() -> Vector2: return grid_band(build_status.offset_bottom, build_menu.size.y) # How wide one column is given. Never wider than looks like a grid, never # narrower than a button, and otherwise whatever the panel has room for. func menu_column_spacing(columns: int) -> float: var room: float = build_menu.size.x - MENU_SIDE_MARGIN * 2.0 if room <= 0.0: room = 672.0 return maxf(MENU_MIN_SPACING, minf(MENU_NATURAL_SPACING, room / float(columns))) func menu_label_half(columns: int) -> float: return menu_column_spacing(columns) * MENU_LABEL_SHARE func menu_label_height(columns: int) -> float: return MENU_LABEL_H_ONE if menu_label_half(columns) >= MENU_ONE_LINE_HALF \ else MENU_LABEL_H_TWO func menu_cost_offset(columns: int) -> float: return MENU_LABEL_OFFSET + menu_label_height(columns) + MENU_LABEL_GAP # How tall one row is, end to end - the cost strip is the last thing in it. func menu_row_height(columns: int) -> float: return menu_cost_offset(columns) + MENU_COST_H # The fewest columns whose rows fit the band. Two is the shape the menu is # meant to have; wider is what a full catalog - or a short window - forces. func menu_columns(count: int) -> int: var widest: int = maxi(mini(MENU_MAX_COLUMNS, int(floor((build_menu.size.x - MENU_SIDE_MARGIN * 2.0) / MENU_MIN_SPACING))), 2) for columns in range(2, widest + 1): if _rows_fit(int(ceil(float(count) / float(columns))), columns): return columns return widest func menu_rows(count: int) -> int: var columns: int = menu_columns(count) return maxi(int(ceil(float(count) / float(columns))), 1) func _rows_fit(rows: int, columns: int) -> bool: var height: float = menu_row_height(columns) var band: Vector2 = menu_band() if rows <= 1: return band.y - band.x >= height return (band.y - band.x - height) / float(rows - 1) >= height # Where one row starts. Top of the band, with the pitch closed up when it is # tight - but never below the row height, so two rows cannot land on each # other however many structures there are, or however short the window (#136, # #144). func menu_row_top(row: int, rows: int, columns: int) -> float: return grid_row_top(row, rows, menu_row_height(columns), menu_band(), MENU_NATURAL_SPACING) 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) var columns: int = menu_columns(shown.size()) var rows: int = menu_rows(shown.size()) var spacing: float = menu_column_spacing(columns) var label_half: float = menu_label_half(columns) var label_h: float = menu_label_height(columns) var cost_offset: float = menu_cost_offset(columns) for i in range(shown.size()): var row: int = i / columns # A short last row is CENTRED rather than left-packed, so it does not # read as a row with something missing beside it. var in_row: int = mini(shown.size() - row * columns, columns) var col: int = i % columns var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * spacing var top: float = menu_row_top(row, rows, columns) _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) _place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h) _place_entry(shown[i][3], cx, top + 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] # #138: a price now names several resources, and what the player # holds of each is on the tag - so it is re-stated here, live, the # way the craft menu's already was. Re-stating the same price is # cheap; PriceTag only rebuilds when the price itself changes. entry[3].show_price(GameState.building_costs(String(definition.definition_id))) 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() # Everything the player can PRESS, as opposed to steer with. Used by the # prologue (#122), which takes them all away at once. func _hide_all_actions() -> void: current_building = null pending_building = null current_extractor_index = -1 pending_extractor_index = -1 current_landing_planet = null context_actions_expanded = false for control in [build_button, action_button, building_action_button, landing_button, transfer_button, hangar_button]: if control != null: control.visible = false 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 # --- the outpost's garrison panel (#103) ----------------------------------- func _close_garrison_menu() -> void: garrison_menu.visible = false garrison_building_id = 0 func _refresh_garrison() -> void: if garrison_building_id == 0: return garrison_panel.building_id = garrison_building_id garrison_panel.refresh() var ordered: int = GameState.garrison_desired_total(garrison_building_id) var crewed: int = GameState.garrison_crew(garrison_building_id).size() if ordered == 0: garrison_status.text = "No vessels ordered. Only armed vessels can hold a position." elif crewed >= ordered: garrison_status.text = "Garrison at full strength" else: garrison_status.text = "%d of %d berths crewed - vessels are sent from the mothership as they become available" \ % [crewed, ordered] # --- the planning centre's standing orders (#106) --------------------------- func _close_planning_menu() -> void: planning_menu.visible = false func _refresh_planning() -> void: planning_panel.refresh() var short: PackedStringArray = PackedStringArray() for vessel_type in GameState.planned_vessel_types(): var missing: int = GameState.vessel_shortfall(String(vessel_type)) if missing > 0: short.append("%d %s" % [missing, GameState.vessel_name(String(vessel_type))]) if short.is_empty(): planning_status.text = "Every standing order is met or already being built" else: planning_status.text = "Short: %s" % ", ".join(short) # The ORE half of a price, which is all some callers want (the info popup's # headline, the tests). costs_of() below is the whole of it. func cost_of(type: String) -> int: return int(costs_of(type).get(GameState.RESOURCE_ORE, 0)) func costs_of(type: String) -> Dictionary: return GameState.building_costs(type) func _show_building_info(type: String) -> void: var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) if definition == null: return var costs: Dictionary = GameState.building_costs(type) 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.") # The same pooled test the build itself uses. Asking the bank here while # charging bank-plus-hold below would hide the build button for something # the player can in fact afford. if can_build and not GameState.can_afford_resources(costs): # #137: no sentence about the shortfall. The price tag below carries # what is held over what is wanted, in red - saying it twice, in two # different shapes, is what the ticket is about. can_build = false info_title.text = definition.display_name info_message.text = "\n\n".join(lines) info_cost.show_price(costs) 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 String(planet.resource_type) == "": # #63: a research_lab planet's whole point is that it yields # nothing to mine - an extractor there would sit on an empty # resource id with no ResourceDefinition, breaking its own # glyph/HUD rendering the moment it produced anything. build_status.text = "%s has nothing to extract" % planet.planet_name return if not GameState.spend_resources(GameState.building_costs(type)): build_status.text = _cannot_afford(type) 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_resources(GameState.building_costs(type)): build_status.text = _cannot_afford(type) 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) # #138: a structure can want more than rocks, so the refusal has to name # which of them is short rather than always saying "rocks". Built from the # price itself, so a cost gaining a resource needs nothing here. func _cannot_afford(type: String) -> String: var missing: Array = [] for resource_type in GameState.building_costs(type): var id: String = String(resource_type) var want: int = int(GameState.building_costs(type)[resource_type]) var have: int = GameState.available_resource(id) if have < want: missing.append("%s (need %d, have %d)" % [GameState.resource_name(id), want, have]) if missing.is_empty(): return "" return "Not enough %s" % " and ".join(missing) func _try_place(type: String) -> void: if not GameState.spend_resources(GameState.building_costs(type)): build_status.text = _cannot_afford(type) return building_field.place(type, fleet.get_centroid()) build_menu.visible = false # --- research (the mothership's skill tree) -------------------------------- # A second tap on the node already selected buys it (#83). Routed into the # same handler the buy button used, so the affordability re-check and the # failure message it does are not duplicated here. func _on_skill_node_confirmed(_id: String) -> void: _on_unlock_pressed() 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.show_price({}) skill_status.text = "Fleet capacity %d · Research points %d" \ % [GameState.max_fleet_size(), GameState.research_points] return var node: Dictionary = GameState.skill_node(id) if node.is_empty(): return var points_needed: int = GameState.skill_research_point_cost(id) # #134: the explanation and the price no longer share a column. The text # is the left half of the band and the tag is the right half, so a long # description can grow without ending up on top of the number - which is # what it used to do, the two being centred over each other. # # Both shortfalls it used to spell out - ore and research points - are # now entries on the tag, held-over-needed, in red when short. One place, # and it says WHICH one is missing without a sentence per case. skill_detail.text = "%s\n%s" % [node.name, node.desc] skill_cost.show_price(GameState.skill_costs(id), points_needed) if GameState.is_unlocked(id): skill_status.text = "Researched" elif not GameState.prerequisite_met(id): skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name elif not skill_cost.is_affordable(): # Deliberately blank. The red on the tag is the whole message; the # line here used to repeat it in words and, being centred, repeat it # on top of the number as well. skill_status.text = "" else: # The button that used to appear here was the only thing saying "this # is buyable". With it gone the line has to say so itself, or an # affordable node and an unaffordable one look identical. skill_status.text = "Tap again to research" 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) # --- the craft menu's own layout (#111) ------------------------------------ # # Six vessels were authored as one row of 65px buttons with 105px captions # overlapping each other, which left "Energy Propulser" clipped and the price # crushed onto a second line under it. Laid out here instead, in two rows of # three, so a cell is wide enough for the name to breathe - and the price is # glyphs rather than words (see PriceTag, which every menu now shares). # # #147: "prices are far from the button they are about, and when many # different resources are needed the costs overlap." Both were true, and # both were the same cause - a cell laid out with more slack than the row # pitch had room for. The caption box was 62px for a one-line name, which # pushed the price 70px clear of the button it belonged to; and the price # was given 184px of width in a 190px column, so a two-resource price ran # out of its own box and into its neighbour's. # # The caption is one line tall now and the price sits directly under it, and # PriceTag puts each resource on its own line when they will not fit across # (which in a column this narrow means whenever there is more than one). # Rows are placed off the measured band rather than a written-down pair of # tops, for the same reason the build menu's are - see grid_band(). const CRAFT_COLUMNS: int = 3 const CRAFT_ROW_PITCH: float = 260.0 const CRAFT_COL_X: float = 190.0 const CRAFT_BUTTON_HALF: float = 56.0 const CRAFT_BUTTON_H: float = 100.0 const CRAFT_LABEL_HALF: float = 92.0 const CRAFT_LABEL_H: float = 44.0 const CRAFT_LABEL_OFFSET: float = 106.0 const CRAFT_PRICE_HALF: float = 92.0 # Room for the tallest price any vessel has, stacked. Read from the tag # rather than assumed, so a vessel priced in three things still fits. const CRAFT_PRICE_H: float = 86.0 const CRAFT_PRICE_OFFSET: float = 154.0 var craft_price_strips: Array = [] # #137: the ship lab's own price tags, built here rather than authored in the # scene for the same reason the craft menu's are - four identical widgets # differing only in where they sit. var lab_price_tags: Array = [] func _build_craft_price_strips() -> void: var strip_script: Script = load("res://ui/PriceTag.gd") for i in range(craft_vessel_buttons.size()): var strip: Control = strip_script.new() strip.name = "CraftPrice%d" % i # Centre anchors, matching the buttons and captions authored in the # scene - _place_entry writes offsets, and an offset means something # different against a top-left anchor. strip.anchor_left = 0.5 strip.anchor_right = 0.5 strip.anchor_top = 0.5 strip.anchor_bottom = 0.5 craft_menu.add_child(strip) craft_price_strips.append(strip) # One tag per upgrade slot, seated in the gap the caption's second line used # to occupy. The captions above them shrink to one line to make room, so the # option block is the same height it was and the row below it does not move. const LAB_TAG_H: float = 38.0 const LAB_LABEL_H: float = 40.0 func _build_lab_price_tags() -> void: var tag_script: Script = load("res://ui/PriceTag.gd") for i in range(lab_upgrade_labels.size()): var label: Control = lab_upgrade_labels[i] var tag: Control = tag_script.new() tag.name = "LabPrice%d" % i tag.anchor_left = label.anchor_left tag.anchor_right = label.anchor_right tag.anchor_top = label.anchor_top tag.anchor_bottom = label.anchor_bottom tag.offset_left = label.offset_left tag.offset_right = label.offset_right # Under the caption, which is cut to a single line here so the two # together occupy what the two-line caption used to. label.offset_bottom = label.offset_top + LAB_LABEL_H tag.offset_top = label.offset_bottom + 2.0 tag.offset_bottom = tag.offset_top + LAB_TAG_H lab_menu.add_child(tag) lab_price_tags.append(tag) func craft_row_height() -> float: return CRAFT_PRICE_OFFSET + CRAFT_PRICE_H func _layout_craft_menu() -> void: var rows: int = maxi(int(ceil(float(craft_vessel_buttons.size()) / float(CRAFT_COLUMNS))), 1) var band: Vector2 = grid_band(craft_status.offset_bottom, craft_menu.size.y) for i in range(craft_vessel_buttons.size()): var row: int = i / CRAFT_COLUMNS var column: int = i % CRAFT_COLUMNS var cx: float = (float(column) - 1.0) * CRAFT_COL_X var top: float = grid_row_top(row, rows, craft_row_height(), band, CRAFT_ROW_PITCH) _place_entry(craft_vessel_buttons[i], cx, top, CRAFT_BUTTON_HALF, CRAFT_BUTTON_H) _place_entry(craft_vessel_labels[i], cx, top + CRAFT_LABEL_OFFSET, CRAFT_LABEL_HALF, CRAFT_LABEL_H) if i < craft_price_strips.size(): _place_entry(craft_price_strips[i], cx, top + CRAFT_PRICE_OFFSET, CRAFT_PRICE_HALF, CRAFT_PRICE_H) 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] # #111: the name alone. The price is the strip under it, in glyphs. craft_vessel_labels[i].text = String(definition.display_name) if i < craft_price_strips.size(): craft_price_strips[i].set_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 · %d RP" % [ GameState.vessel_name(lab_vessel), GameState.research_points] 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 i < lab_price_tags.size(): lab_price_tags[i].visible = visible if not visible: continue var option: Dictionary = options[i] # #137: the price is a tag under the option, the way it is under a # build option and under a vessel in the craft menu. It used to be a # second line of the caption - "25 ore · 10 gas · 2 RP" - which is # the same information in a shape no other menu used. var maxed: bool = not bool(option.unlock) and GameState.upgrade_maxed(option.id) 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" % [option.name, level_note] if i < lab_price_tags.size(): var tag: Control = lab_price_tags[i] tag.visible = not maxed if maxed: tag.show_price({}) else: tag.show_price(option.costs if bool(option.unlock) \ else GameState.research_costs(option.id), GameState.research_point_cost(String(option.id))) 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] var choice_points: int = GameState.research_point_cost(String(choice.id)) lab_detail.text = "%s\n%s" % [choice.name, choice.desc] if choice_points > 0: lab_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, choice_points] 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(): var queued_points: int = GameState.research_point_cost(String(queued.id)) if not bool(queued.started) and GameState.research_points < queued_points: # #63: distinct from "Waiting for resources" - flying more ore/gas # to this lab cannot fix a research-point shortfall, only # conquering a research lab elsewhere can. lab_status.text = "Need %d research point%s (have %d)" \ % [queued_points, "" if queued_points == 1 else "s", GameState.research_points] else: 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 GameState.building_has_capability(b, "bank"): # Worth naming as the bank rather than as a structure: it is the # shared pool every building's transporters draw from, not just # another store. (It is no longer where ore has to be to be SPENT - # purchases now draw on the fleet hold too.) 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 GameState.building_has_capability(building, "bank"): 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 # How many vessel types sit side by side in the hangar. Six in a single row # gave each one about 100px while its two-line label needs 155, so the names # ran into each other - "InterceptSheld VessGas Bomber". Three columns give # each cell room, and the second row costs nothing: the lower half of the # menu was empty. const HANGAR_COLUMNS: int = 3 # #120: room for a two-line caption under each icon, and air around the rule # that separates the two halves. const HANGAR_LABEL_GAP: float = 2.0 const HANGAR_DIVIDER_GAP: float = 10.0 # Where the rule between "available" and "in the fleet" is drawn. Computed by # the layout and read by the menu's own _draw, so the line cannot drift away # from the sections it divides. var hangar_divider_y: float = 0.0 # Arranges both hangar sections as grids sized from the space actually # available, rather than from the single row the scene authored. A seventh # vessel type now wraps instead of squeezing the other six. func _layout_hangar() -> void: var count: int = hangar_available_slots.size() if count == 0: return var width: float = hangar_menu.size.x var columns: int = mini(HANGAR_COLUMNS, count) var rows: int = int(ceil(float(count) / float(columns))) # The band between the first section's heading and the status line under # both of them. Derived, so moving either in the scene moves the grids. var top: float = hangar_available_title.position.y + hangar_available_title.size.y + 8.0 var bottom: float = hangar_status.position.y - 12.0 var title_height: float = hangar_fleet_title.size.y var section_height: float = (bottom - top - title_height - 16.0) * 0.5 if section_height <= 0.0: return var cell_width: float = width / float(columns) var cell_height: float = section_height / float(rows) # #120: the icon gives up height so the caption under it has somewhere to # go. Vessel names are two and three words long and were wrapping into a # box barely tall enough for one line, so the second line was clipped and # a "Gas Bomber" read as "Gas". var slot_size: float = minf(cell_height * 0.46, cell_width * 0.40) # #143: TWO gaps, not one - above the caption and below it. With a single # gap the box ended exactly HANGAR_LABEL_GAP past the bottom of its own # cell, which is inside the next row's icon. Two pixels of overhang is # not much on its own, but it is the difference between "the caption # cannot reach the row below" and "the caption starts in it". var label_height: float = maxf( cell_height - slot_size - HANGAR_LABEL_GAP * 2.0, 1.0) _layout_hangar_section(hangar_available_slots, hangar_available_labels, top, columns, cell_width, cell_height, slot_size, label_height, width) # AFTER the first section's last row, not before it. Placing the heading # by subtracting from the second section's top put it back inside the # first one, straight over its bottom row. var available_bottom: float = top + section_height # #120: a rule between the two halves. They are different questions - # what is parked here, and what is flying - and with only a heading # between them the grids read as one long list. hangar_divider_y = available_bottom + HANGAR_DIVIDER_GAP hangar_fleet_title.position.y = hangar_divider_y + HANGAR_DIVIDER_GAP var fleet_top: float = hangar_fleet_title.position.y + title_height + 8.0 _layout_hangar_section(hangar_fleet_slots, hangar_fleet_labels, fleet_top, columns, cell_width, cell_height, slot_size, label_height, width) hangar_menu.queue_redraw() func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: int, cell_width: float, cell_height: float, slot_size: float, label_height: float, width: float) -> void: # A zero here is fatal on the web and silent everywhere else: wasm TRAPS # on integer % 0 ("remainder by zero") and kills the engine mid-frame, # while a native build shrugs it off. That difference is why this class # of bug survives every test and only appears in a browser. if columns <= 0: return for i in range(slots.size()): var column: int = i % columns var row: int = i / columns # Control.position is in PARENT space, whatever the node's anchors # are - the authored offsets being negative (anchored to the menu's # centre) does not make position centre-relative too. var cell_x: float = float(column) * cell_width var cell_y: float = top + float(row) * cell_height var slot: Control = slots[i] slot.position = Vector2(cell_x + (cell_width - slot_size) * 0.5, cell_y) slot.size = Vector2(slot_size, slot_size) var label: Label = labels[i] label.position = Vector2(cell_x, cell_y + slot_size + HANGAR_LABEL_GAP) label.size = Vector2(cell_width, label_height) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_TOP label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART # #143: a caption is "Gas Bomber\n2 available" - two lines before # autowrap, three after it in a narrow column - at a font size fixed # in the scene. A Label does not clip by default, so the third line # was drawn straight over the icon of the row below it. # # Two things, and both are wanted. The size is fitted to the box the # layout above worked out, so the text has somewhere to go; and the # box clips, so however the catalog grows - a longer vessel name, a # fourth line - nothing can ever reach the next row again. The first # is what keeps it legible, the second is what keeps it correct. label.add_theme_font_size_override("font_size", _hangar_caption_size(label_height)) label.clip_text = true # The largest caption that still fits HANGAR_CAPTION_LINES lines into the # height the grid could spare, within what is readable at arm's length. const HANGAR_CAPTION_LINES: float = 3.0 const HANGAR_CAPTION_MIN: int = 13 const HANGAR_CAPTION_MAX: int = 22 # Godot lays a line out taller than its nominal font size; measured against # the project font rather than guessed, this is the ratio that keeps three # lines inside the box. const HANGAR_LINE_RATIO: float = 1.25 func _hangar_caption_size(label_height: float) -> int: return clampi(int(label_height / (HANGAR_CAPTION_LINES * HANGAR_LINE_RATIO)), HANGAR_CAPTION_MIN, HANGAR_CAPTION_MAX) 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()