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 action_label: Label = get_node("../UI/BuildActionLabel") @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 planet_field: Node2D = get_node("../PlanetField") @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel") @onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber") @onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton") @onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton") @onready var craft_menu: Control = get_node("../UI/CraftMenu") @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") @onready var 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_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 = "" var current_building: Node2D = null var pending_building: Node2D = null var pending_time: float = 0.0 func _ready() -> void: build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(func(): build_menu.visible = false) 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) info_close_button.pressed.connect(_close_info) info_build_button.pressed.connect(_confirm_build) action_button.pressed.connect(_on_action_pressed) craft_interceptor_button.pressed.connect(_on_craft_interceptor) craft_close_button.pressed.connect(func(): craft_menu.visible = false) 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) build_menu.visible = false info_popup.visible = false craft_menu.visible = false formation_menu.visible = false action_button.visible = false action_label.visible = false # 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 func _unhandled_input(event: InputEvent) -> void: # The formation editor is driven from here rather than via _gui_input, # for the same reason as the joystick: its Control must not swallow # touches, or dragging a slot would never reach it. if not formation_menu.visible: return if event is InputEventScreenTouch and formation_editor.handle_touch(event): get_viewport().set_input_as_handled() elif event is InputEventScreenDrag and formation_editor.handle_drag(event): 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 if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible: current_building = null pending_building = null action_button.visible = false action_label.visible = 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 != null and detected.building_type == GameState.BUILDING_MOTHERSHIP: detected = null # nothing to do at a mothership beyond respawning 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 action_button.visible = current_building != null action_label.visible = current_building != null if current_building != null: action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP action_label.text = current_building.get_display_name() action_button.queue_redraw() 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 if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: craft_menu.visible = true _refresh_craft_status() 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() func _refresh_build_status() -> void: # The extractor option is always listed, but greyed unless the fleet is # actually over a planet - showing it only sometimes would leave the # player wondering whether the game has it at all. build_extractor_button.enabled = _planet_under_fleet() != null # 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 _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_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 # Anything that would stop this particular placement is spelled out in # the same popup, rather than silently failing after the press. if 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 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 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 func _refresh_craft_status() -> void: craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [ GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size() ] func _on_craft_interceptor() -> void: if fleet.ships.size() >= GameState.formation.size(): craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size() return if not GameState.spend(GameState.SHIP_COST): craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks] return fleet.add_ship() _refresh_craft_status() func _on_formation_changed() -> void: fleet.apply_formation() func _on_reset_formation() -> void: GameState.reset_formation() fleet.apply_formation() formation_editor.queue_redraw()