spaceship/controllers/BuildController.gd
Loïc Wisniewski 8412280e71 #118/#121: the speed fader and the formation editor under a mouse
Both read raw touches, and the project no longer turns clicks into them - the
same routing gap #93 fixed in the research tree. The boost was unreachable on
a desktop and the formation could not be rearranged at all.

Wrappers over the same press/drag logic in each case, never a second
implementation, so the touch and mouse paths cannot drift apart. The wheel
replaces the pinch in the formation editor: a mouse has no second finger, and
zoom is the other half of what that panel does.

The fader gets first refusal on a mouse press in GameController, so dragging
the boost does not also pan or zoom the world behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:59:24 +02:00

1574 lines
65 KiB
GDScript

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")
@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 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: NeonNumber = 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()
_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
_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_number: NeonNumber = 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_number.amount = definition.cost
build_menu_entries.append([definition.definition_id, button, label, cost_number, 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.
const MENU_ROW_TOPS: Array = [-300.0, -77.0, 146.0, 369.0]
const MENU_COL_X: float = 140.0
const MENU_BUTTON_HALF: float = 64.0
const MENU_BUTTON_H: float = 100.0
const MENU_LABEL_HALF: float = 150.0
const MENU_LABEL_H: float = 52.0
const MENU_COST_HALF: float = 60.0
const MENU_COST_H: float = 44.0
# Gaps from each row's own top to where the label/cost start, sized to the
# shrunk MENU_*_H values above.
const MENU_LABEL_OFFSET: float = 106.0
const MENU_COST_OFFSET: float = 162.0
const MENU_WIDE_THRESHOLD: int = 8
const MENU_ROW_TOPS_WIDE: Array = [-330.0, -85.0, 160.0]
const MENU_COL_X_WIDE: float = 224.0
const MENU_LABEL_HALF_WIDE: float = 108.0
const MENU_LABEL_H_WIDE: float = 68.0
const MENU_COST_OFFSET_WIDE: float = 178.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)
var wide: bool = shown.size() > MENU_WIDE_THRESHOLD
var columns: int = 3 if wide else 2
var tops: Array = MENU_ROW_TOPS_WIDE if wide else MENU_ROW_TOPS
var col_x: float = MENU_COL_X_WIDE if wide else MENU_COL_X
var label_half: float = MENU_LABEL_HALF_WIDE if wide else MENU_LABEL_HALF
var label_h: float = MENU_LABEL_H_WIDE if wide else MENU_LABEL_H
var cost_offset: float = MENU_COST_OFFSET_WIDE if wide else MENU_COST_OFFSET
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) * col_x * 2.0 \
/ maxf(float(columns) - 1.0, 1.0)
var top: float = tops[mini(row, tops.size() - 1)]
_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]
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)
func cost_of(type: String) -> int:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
return int(definition.cost) if definition != null else 0
func _show_building_info(type: String) -> void:
var definition: Resource = BUILDING_DEFINITIONS.get_definition(type)
if definition == null:
return
var cost: int = int(definition.cost)
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(cost):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.available_ore()])
can_build = false
info_title.text = definition.display_name
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
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(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.available_ore()]
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(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [
definition.cost, GameState.available_ore()
]
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, 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.available_ore()]
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.amount = 0
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)
skill_detail.text = "%s\n%s" % [node.name, node.desc]
if points_needed > 0:
skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed]
skill_cost.amount = int(node.cost)
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 GameState.can_afford(int(node.cost)):
# available_ore(), not the bank alone: research is paid for the same
# pooled way as everything else, so quoting the bank here would tell
# the player they cannot afford something they can.
skill_status.text = "Not enough rocks - you have %d" % GameState.available_ore()
elif GameState.research_points < points_needed:
# #63: a distinct reason from the ore one above - conflating them
# would leave the player trying to fix the wrong shortage.
skill_status.text = "Not enough research points - you have %d, need %d" \
% [GameState.research_points, points_needed]
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 VesselPriceStrip).
const CRAFT_COLUMNS: int = 3
const CRAFT_ROW_TOPS: Array = [-96.0, 104.0]
const CRAFT_COL_X: float = 190.0
const CRAFT_BUTTON_HALF: float = 56.0
const CRAFT_BUTTON_H: float = 112.0
const CRAFT_LABEL_HALF: float = 92.0
const CRAFT_LABEL_H: float = 62.0
const CRAFT_LABEL_OFFSET: float = 118.0
const CRAFT_PRICE_HALF: float = 92.0
const CRAFT_PRICE_H: float = 32.0
const CRAFT_PRICE_OFFSET: float = 182.0
var craft_price_strips: Array = []
func _build_craft_price_strips() -> void:
var strip_script: Script = load("res://ui/VesselPriceStrip.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)
func _layout_craft_menu() -> void:
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 = CRAFT_ROW_TOPS[mini(row, CRAFT_ROW_TOPS.size() - 1)]
_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 not visible:
continue
var option: Dictionary = options[i]
var price_text: String = _format_costs(option.costs if bool(option.unlock) \
else GameState.research_costs(option.id))
var point_cost: int = GameState.research_point_cost(String(option.id))
if point_cost > 0:
price_text += " · %d RP" % point_cost
if not bool(option.unlock) and GameState.upgrade_maxed(option.id):
price_text = "MAX"
var level_note: String = "" if bool(option.unlock) else " %d/%d" % [
GameState.upgrade_level(option.id), int(GameState.upgrade_def(option.id).max_level)]
lab_upgrade_labels[i].text = "%s%s\n%s" % [option.name, level_note, price_text]
lab_upgrade_buttons[i].icon = _upgrade_icon(option)
lab_upgrade_buttons[i].glow_color = _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)
var label_height: float = maxf(cell_height - slot_size - HANGAR_LABEL_GAP, 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 + 4.0)
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
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()