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 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 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_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) 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 action_button.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 or skill_menu.visible func _unhandled_input(event: InputEvent) -> void: # 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 and skill_tree.handle_touch(event): get_viewport().set_input_as_handled() return 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 \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) if world_map.visible or build_menu.visible or craft_menu.visible \ or formation_menu.visible or skill_menu.visible: current_building = null pending_building = null action_button.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 != 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 if current_building != null: action_button.icon = _action_icon_for(current_building.building_type) action_button.queue_redraw() func _action_icon_for(type: String) -> int: match type: GameState.BUILDING_SHIP_CRAFTER: return IconButton.Icon.SHIP 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 if current_building.building_type == GameState.BUILDING_MOTHERSHIP: skill_menu.visible = true _refresh_skill_menu() elif 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: # Every option is always listed, greyed when it cannot be used - showing # them only sometimes would leave the player wondering whether the game # has them at all. Greyed buttons still emit `pressed`, so each one can # explain itself in the info popup. build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER) 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 _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 # 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 _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.max_fleet_size() ] func _on_craft_interceptor() -> void: if fleet.ships.size() >= GameState.max_fleet_size(): craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_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()