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 build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton") @onready var build_menu_entries: Array = [ [GameState.BUILDING_MOTHERSHIP, build_mothership_button, get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")], [GameState.BUILDING_SHIP_CRAFTER, build_crafter_button, get_node("../UI/BuildMenu/CrafterLabel"), get_node("../UI/BuildMenu/CrafterCost")], [GameState.BUILDING_COMMAND_CENTER, build_command_button, get_node("../UI/BuildMenu/CommandLabel"), get_node("../UI/BuildMenu/CommandCost")], [GameState.BUILDING_EXTRACTOR, build_extractor_button, get_node("../UI/BuildMenu/ExtractorLabel"), get_node("../UI/BuildMenu/ExtractorCost")], [GameState.BUILDING_TRANSPORT_HUB, build_hub_button, get_node("../UI/BuildMenu/TransportHubLabel"), get_node("../UI/BuildMenu/TransportHubCost")], ] @onready var planet_field: Node2D = get_node("../PlanetField") @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel") @onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber") @onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton") @onready var info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton") @onready var craft_menu: Control = get_node("../UI/CraftMenu") @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") @onready var craft_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton") @onready var craft_store_button: IconButton = get_node("../UI/CraftMenu/StoreOreButton") @onready var craft_store_label: Label = get_node("../UI/CraftMenu/StoreOreLabel") @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") @onready var load_ore_button: IconButton = get_node("../UI/SkillMenu/LoadOreButton") @onready var load_ore_label: Label = get_node("../UI/SkillMenu/LoadOreLabel") @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_TRANSPORT_HUB: { "title": "Transport Hub", "body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.", }, GameState.BUILDING_EXTRACTOR: { "title": "Extractor", "body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.", }, } # Which structure the popup is currently offering to build, or "" when it is # only explaining something that cannot be built right now. var pending_build_type: String = "" # Which crafter the craft menu is showing (see _on_action_pressed). var craft_building_id: int = 0 # The craft panel refreshes every frame while it is open (so a delivery # arriving visibly works the queue down). That overwrote any message the # moment after it was set, which made a working button look dead - the store # button in particular. Messages therefore have to outlive a refresh. var craft_message: String = "" var craft_message_until: int = 0 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) build_hub_button.pressed.connect(_on_place_transport_hub) 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) load_ore_button.pressed.connect(_on_load_ore) craft_transporter_button.pressed.connect(_on_craft_transporter) craft_store_button.pressed.connect(_on_store_ore) 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: skill_tree.handle_touch(event) # Handled either way. A touch that lands on the panel but misses a # node is still a touch ON THE PANEL - letting it through moved the # fleet and pinch-zoomed the world behind the menu, because # GameController sits on the root node and sees whatever this leaves # unhandled. get_viewport().set_input_as_handled() return if formation_menu.visible: if event is InputEventScreenTouch: formation_editor.handle_touch(event) elif event is InputEventScreenDrag: formation_editor.handle_drag(event) get_viewport().set_input_as_handled() return if build_menu.visible or craft_menu.visible or world_map.visible: # These panels have no raw-input handler of their own, but the world # behind them must still not react. get_viewport().set_input_as_handled() func _process(delta: float) -> void: _check_fleet_wipe() if build_menu.visible: # Keep the greyed state live while the menu is open, so flying onto # a planet lights the option up without reopening it. build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) build_extractor_button.queue_redraw() if craft_menu.visible: # Live, so a delivery arriving while the menu is open visibly works # the queue down instead of looking stuck. _refresh_craft_status() if 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_TRANSPORT_HUB: return IconButton.Icon.TRANSPORT_HUB 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_load_ore() _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() # Grid geometry, shared with the layout below. Rows of two, and the menu is # rebuilt from whatever is currently researched (#21) rather than being a # fixed arrangement with holes punched in it. const MENU_ROW_TOPS: Array = [-300.0, -10.0, 280.0] const MENU_COL_X: float = 140.0 const MENU_BUTTON_HALF: float = 64.0 const MENU_BUTTON_H: float = 128.0 const MENU_LABEL_HALF: float = 150.0 const MENU_LABEL_H: float = 66.0 const MENU_COST_HALF: float = 60.0 const MENU_COST_H: float = 52.0 func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void: node.offset_left = cx - half node.offset_right = cx + half node.offset_top = top node.offset_bottom = top + height func _layout_build_menu() -> void: # Structures the player has not researched are hidden outright (#21), and # what remains is repacked so there are no gaps where they used to be. # This reverses the earlier decision to always list everything greyed # out; an option you cannot explain is apparently worse than one you # cannot see yet. var shown: Array = [] for entry in build_menu_entries: var visible: bool = GameState.is_building_unlocked(entry[0]) entry[1].visible = visible entry[2].visible = visible entry[3].visible = visible if visible: shown.append(entry) for i in range(shown.size()): var row: int = i / 2 # Centred when it is the only one on its row, rather than hanging in # the left column looking like something is missing beside it. var alone: bool = (i == shown.size() - 1) and (i % 2 == 0) var cx: float = 0.0 if alone else (-MENU_COL_X if i % 2 == 0 else MENU_COL_X) var top: float = MENU_ROW_TOPS[mini(row, MENU_ROW_TOPS.size() - 1)] _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) _place_entry(shown[i][2], cx, top + 134.0, MENU_LABEL_HALF, MENU_LABEL_H) _place_entry(shown[i][3], cx, top + 204.0, MENU_COST_HALF, MENU_COST_H) func _refresh_build_status() -> void: _layout_build_menu() # Researched but not usable right here is still shown, greyed: an # extractor needs a planet under the fleet, which is a "not now" rather # than a "not yet". build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. if _planet_under_fleet() != null: build_status.text = "Over %s - an extractor can be built here" % _planet_under_fleet().planet_name else: build_status.text = "" func _on_place_mothership() -> void: _show_building_info(GameState.BUILDING_MOTHERSHIP) func _on_place_crafter() -> void: _show_building_info(GameState.BUILDING_SHIP_CRAFTER) func _on_place_command_center() -> void: _show_building_info(GameState.BUILDING_COMMAND_CENTER) func _on_place_transport_hub() -> void: _show_building_info(GameState.BUILDING_TRANSPORT_HUB) func _on_place_extractor() -> void: # The option stays pressable while greyed, so this is also how a # not-over-a-planet extractor explains itself. _show_building_info(GameState.BUILDING_EXTRACTOR) func cost_of(type: String) -> int: match type: GameState.BUILDING_TRANSPORT_HUB: return GameState.TRANSPORT_HUB_COST GameState.BUILDING_MOTHERSHIP: return GameState.MOTHERSHIP_COST GameState.BUILDING_SHIP_CRAFTER: return GameState.SHIP_CRAFTER_COST GameState.BUILDING_COMMAND_CENTER: return GameState.COMMAND_CENTER_COST _: return GameState.EXTRACTOR_COST func _show_building_info(type: String) -> void: var info: Dictionary = BUILDING_INFO[type] var cost: int = cost_of(type) var lines: Array = [info.body] var can_build: bool = true # Locked structures still list themselves, greyed, so the player can see # what exists and what research would open it up. if not GameState.is_building_unlocked(type): lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type)) can_build = false # Anything that would stop this particular placement is spelled out in # the same popup, rather than silently failing after the press. if can_build and type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null: lines.append("No planet in reach - fly over one.") can_build = false elif GameState.has_extractor_at(planet.planet_name): lines.append("%s already has an extractor." % planet.planet_name) can_build = false else: lines.append("Will mine %s." % planet.planet_name) elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): lines.append("WARNING: only one may exist - this destroys your current one.") if can_build and not GameState.can_afford(cost): lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks]) can_build = false info_title.text = info.title info_message.text = "\n\n".join(lines) info_cost.amount = cost info_build_button.visible = can_build pending_build_type = type if can_build else "" info_popup.visible = true func _close_info() -> void: info_popup.visible = false pending_build_type = "" func _confirm_build() -> void: var type: String = pending_build_type if type == "": return pending_build_type = "" info_popup.visible = false # Everything is re-checked here rather than trusted from when the popup # opened: the fleet keeps flying while it is up, so it can drift off the # planet an extractor was going to mine. if not GameState.is_building_unlocked(type): return if type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null or GameState.has_extractor_at(planet.planet_name): build_status.text = "No planet in reach any more" return if not GameState.spend(GameState.EXTRACTOR_COST): build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks] return GameState.add_extractor(planet.planet_name, planet.position) build_menu.visible = false return if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): if not GameState.can_afford(GameState.MOTHERSHIP_COST): build_status.text = "Not enough rocks (need %d, have %d)" % [ GameState.MOTHERSHIP_COST, GameState.rocks ] return # Only now is the old one destroyed - never before the new one is # known to be affordable, or the player would be left with none. GameState.remove_buildings_of_type(GameState.BUILDING_MOTHERSHIP) building_field.rebuild_all() _try_place(type, cost_of(type)) func _try_place(type: String, cost: int) -> void: if not GameState.spend(cost): build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.rocks] return building_field.place(type, fleet.get_centroid()) build_menu.visible = false # --- research (the mothership's skill tree) -------------------------------- func _on_skill_node_picked(id: String) -> void: _refresh_skill_menu(id) func _refresh_skill_menu(id: String = "") -> void: if id == "": id = skill_tree.selected_id if id == "": # Nothing chosen yet: point the player at the tree rather than # leaving the panel blank, which reads as broken. skill_detail.text = "Select a node to see what it unlocks." skill_cost.amount = 0 skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size() skill_unlock_button.visible = false return var node: Dictionary = GameState.skill_node(id) if node.is_empty(): return skill_detail.text = "%s\n%s" % [node.name, node.desc] skill_cost.amount = int(node.cost) if GameState.is_unlocked(id): skill_status.text = "Researched" skill_unlock_button.visible = false elif not GameState.prerequisite_met(id): skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name skill_unlock_button.visible = false elif not GameState.can_afford(int(node.cost)): skill_status.text = "Not enough rocks - you have %d" % GameState.rocks skill_unlock_button.visible = false else: skill_status.text = "" skill_unlock_button.visible = true func _on_unlock_pressed() -> void: var id: String = skill_tree.selected_id if id == "": return if not GameState.unlock(id): # Re-check rather than trust the button: rocks can be spent elsewhere # while this menu is open, since haulers keep delivering. _refresh_skill_menu(id) return # A capacity upgrade has to reach the fleet that is already flying, not # just ones rebuilt later. if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0: fleet.apply_formation() skill_tree.queue_redraw() _refresh_skill_menu(id) func _say(message: String) -> void: craft_message = message craft_message_until = Time.get_ticks_msec() + 3000 _refresh_craft_status() func _refresh_craft_status() -> void: # "Here" is the ore delivered to THIS crafter, not the bank: since #16 a # building builds out of its own stock, and showing the bank would say # you can afford something the crafter cannot actually make. var queued: int = GameState.pending_orders(craft_building_id) var stock: int = GameState.building_stock(craft_building_id) var queue_text: String = "" if queued == 0 else " Queued %d" % queued craft_status.text = "Interceptor %d Transporter %d Here %d%s Transporters %d/%d" % [ GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text, GameState.transporters_claimed(), GameState.transport_capacity() ] # Nowhere to station one means the order would be refused, so say so # rather than letting the button look available and do nothing. craft_transporter_button.enabled = GameState.has_transport_hub() \ and GameState.can_station_transporter() craft_transporter_button.queue_redraw() craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo # Deliberately left ENABLED with an empty hold: a greyed-out button that # cannot say why is exactly the dead-button problem this panel already # had. Pressing it explains instead. craft_store_button.enabled = true craft_store_button.queue_redraw() # A full fleet cannot take another vessel, so the order would be refused. # Showing that on the button is the difference between "not available yet" # and a button that looks live and silently does nothing when pressed. var room: bool = fleet.ships.size() + GameState.ships_owed \ + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ < GameState.max_fleet_size() craft_interceptor_button.enabled = room craft_interceptor_button.queue_redraw() # One statement of the fleet count, either way round: the capacity line # replaces the plain readout rather than being bolted in front of it. if room: craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] else: var queued_ships: int = GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) var owed: int = GameState.ships_owed + queued_ships var pending_note: String = "" if owed == 0 else " (%d already on order)" % owed craft_status.text = "No room in the fleet: %d/%d%s - research capacity at the mothership\n%s" % [ fleet.ships.size(), GameState.max_fleet_size(), pending_note, craft_status.text ] # LAST, so a message about what just happened is the first line the # player reads rather than being buried under the standing state. if craft_message != "": if Time.get_ticks_msec() < craft_message_until: craft_status.text = "%s\n%s" % [craft_message, craft_status.text] else: craft_message = "" func _on_craft_interceptor() -> void: if craft_building_id == 0: return # Still refused when there is nowhere to put the vessel - queueing an # order that can never be delivered would just bank ships_owed forever. if fleet.ships.size() + GameState.ships_owed \ + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ >= GameState.max_fleet_size(): _say("Fleet is full (%d slots)" % GameState.max_fleet_size()) return # No affordability check: an order the crafter cannot pay for yet is # QUEUED, and completes by itself once ore is delivered here (#16). But # queueing silently looks identical to a dead button, so it says which # happened and what the crafter is still short of. GameState.queue_order(craft_building_id) _report_order(GameState.PRODUCT_SHIP) func _refresh_load_ore() -> void: load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo] load_ore_button.enabled = GameState.rocks > 0 load_ore_button.queue_redraw() func _on_load_ore() -> void: GameState.withdraw_to_fleet() _refresh_load_ore() func _report_order(product: String) -> void: var short: int = GameState.outstanding_cost(craft_building_id) if short > 0: _say("%s queued - %d more ore must be delivered here" % [ GameState.VESSEL_NAMES.get(product, product), short ]) else: _say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product)) func _on_store_ore() -> void: if craft_building_id == 0: return if GameState.fleet_cargo <= 0: _say("The fleet is carrying nothing - load ore at the mothership first") return var moved: int = GameState.unload_into_building(craft_building_id) _say("Stored %d ore here" % moved) func _on_craft_transporter() -> void: if craft_building_id == 0: return if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER): # Two different refusals, and the player needs to know WHICH. if not GameState.has_transport_hub(): _say("Build a Transport Hub first - a transporter needs somewhere to be stationed") else: _say("All %d berths are taken - build another hub, or research Freight Yards" % GameState.transport_capacity()) return _refresh_craft_status() func _on_formation_changed() -> void: fleet.apply_formation() func _on_reset_formation() -> void: GameState.reset_formation() fleet.apply_formation() formation_editor.queue_redraw()