spaceship/BuildController.gd
2026-08-12 22:47:46 +02:00

1056 lines
42 KiB
GDScript

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_lab_button: IconButton = get_node("../UI/BuildMenu/ShipLabButton")
@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"),
]
@onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel")
@onready var lab_list: Label = get_node("../UI/LabMenu/UpgradeList")
@onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel")
@onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel")
@onready var lab_next_button: IconButton = get_node("../UI/LabMenu/NextButton")
@onready var lab_buy_button: IconButton = get_node("../UI/LabMenu/BuyButton")
@onready var lab_close_button: IconButton = get_node("../UI/LabMenu/CloseButton")
@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_list: Label = get_node("../UI/HangarMenu/ListLabel")
@onready var hangar_status: Label = get_node("../UI/HangarMenu/StatusLabel")
@onready var hangar_next_button: IconButton = get_node("../UI/HangarMenu/NextButton")
@onready var hangar_crew_button: IconButton = get_node("../UI/HangarMenu/CrewButton")
@onready var hangar_close_button: IconButton = get_node("../UI/HangarMenu/CloseButton")
# Which stored vessel the hangar is offering to crew.
var hangar_choice: int = 0
@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")],
[GameState.BUILDING_SHIP_LAB, build_lab_button,
get_node("../UI/BuildMenu/ShipLabLabel"), get_node("../UI/BuildMenu/ShipLabCost")],
]
# Which vessel the lab screen is showing, and which of its upgrades is
# selected. The screen steps through one upgrade at a time rather than
# listing tappable rows: on a phone a row of small hit targets is worse than
# one big Buy button and a Next.
var lab_vessel: String = GameState.VESSEL_INTERCEPTOR
var lab_choice: 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_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton")
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
# --- 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 button that works at every building and in both
# directions, so moving ore is never buried under an unrelated menu.
@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 to_building_label: Label = get_node("../UI/TransferMenu/ToBuildingLabel")
@onready var to_building_batch_button: IconButton = get_node("../UI/TransferMenu/ToBuildingBatchButton")
@onready var to_building_batch_label: Label = get_node("../UI/TransferMenu/ToBuildingBatchLabel")
@onready var to_building_all_button: IconButton = get_node("../UI/TransferMenu/ToBuildingAllButton")
@onready var to_fleet_label: Label = get_node("../UI/TransferMenu/ToFleetLabel")
@onready var to_fleet_batch_button: IconButton = get_node("../UI/TransferMenu/ToFleetBatchButton")
@onready var to_fleet_batch_label: Label = get_node("../UI/TransferMenu/ToFleetBatchLabel")
@onready var to_fleet_all_button: IconButton = get_node("../UI/TransferMenu/ToFleetAllButton")
# 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"), get_node("../UI/TransferMenu/Bar"),
]
@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_SHIP_LAB: {
"title": "Ship Lab",
"body": "Fly the fleet onto it to upgrade each vessel type, and to unlock the shield vessel.",
},
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
# 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 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(_close_build_menu)
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)
build_lab_button.pressed.connect(_on_place_ship_lab)
craft_shield_button.pressed.connect(_on_craft_shield)
lab_close_button.pressed.connect(func(): lab_menu.visible = false)
lab_next_button.pressed.connect(_on_lab_next)
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))
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_transporter_button.pressed.connect(_on_craft_transporter)
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)
hangar_next_button.pressed.connect(_on_hangar_next)
hangar_crew_button.pressed.connect(_on_hangar_crew)
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)
to_building_batch_button.pressed.connect(_on_transfer.bind(true, GameState.CARGO_LOAD_BATCH))
to_building_all_button.pressed.connect(_on_transfer.bind(true, ALL))
to_fleet_batch_button.pressed.connect(_on_transfer.bind(false, GameState.CARGO_LOAD_BATCH))
to_fleet_all_button.pressed.connect(_on_transfer.bind(false, ALL))
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
lab_menu.visible = false
transfer_menu.visible = false
action_button.visible = false
transfer_button.visible = false
hangar_button.visible = false
hangar_menu.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 or transfer_menu.visible \
or hangar_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 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()
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 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
action_button.visible = false
transfer_button.visible = false
hangar_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
# The hangar is where finished vessels are delivered (#23), so it belongs
# at the mothership and nowhere else.
hangar_button.visible = current_building != null \
and current_building.building_type == GameState.BUILDING_MOTHERSHIP
# Every building can hold ore, so the transfer button rides alongside the
# action button rather than being one of the things the action button
# turns into - the two are independent choices at the same structure.
transfer_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_SHIP_LAB:
return IconButton.Icon.SHIP_LAB
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
# 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
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
skill_menu.visible = true
_refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
# 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 current_building.building_type == GameState.BUILDING_SHIP_LAB:
lab_menu.visible = true
_refresh_lab()
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 _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
func _on_place_ship_lab() -> void:
_show_building_info(GameState.BUILDING_SHIP_LAB)
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_SHIP_LAB:
return GameState.SHIP_LAB_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:
# The bank/hold/here figures moved out to the shared header (#24), so this
# line is now only what is specific to a crafter: prices, the queue, and
# berth pressure. It used to restate "Here %d" itself, which is exactly
# the per-panel improvisation the header replaces.
var queued: int = GameState.pending_orders(craft_building_id)
var queue_text: String = "" if queued == 0 else " Queued %d" % queued
craft_status.text = "Interceptor %d Transporter %d%s Transporters %d/%d" % [
GameState.SHIP_COST, GameState.TRANSPORTER_COST, 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_shield_button.enabled = GameState.shield_vessel_unlocked()
craft_shield_button.queue_redraw()
# Fleet capacity no longer gates ORDERING (#23). A finished vessel waits
# at the mothership until the player crews it, so a full fleet is a
# reason not to collect one yet - not a reason the crafter cannot build.
# This is also what stopped the crafter being inert on a fresh save,
# where the fleet starts at exactly its cap.
craft_interceptor_button.enabled = true
craft_interceptor_button.queue_redraw()
craft_status.text += " Fleet %d/%d Ready %d" % [
fleet.ships.size(), GameState.max_fleet_size(), GameState.stored_vessels.size()
]
# LAST, so a message about what just happened is the first line the
# player reads rather than being buried under the standing state.
_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:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
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 _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_craft_transporter() -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
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()
# --- 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 = []
if lab_vessel == GameState.VESSEL_SHIELD and not GameState.shield_vessel_unlocked():
options.append({"unlock": true, "name": "Unlock Shield Vessel",
"desc": "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up.",
"cost": GameState.UNLOCK_SHIELD_COST})
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:
lab_vessel = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_TRANSPORTER][index]
lab_choice = 0
_refresh_lab()
func _on_lab_next() -> void:
var options: Array = _lab_options()
if options.is_empty():
return
lab_choice = (lab_choice + 1) % options.size()
_refresh_lab()
func _on_lab_buy() -> void:
var options: Array = _lab_options()
if options.is_empty():
return
var choice: Dictionary = options[lab_choice]
if choice.unlock:
if GameState.unlock_shield_vessel():
lab_status.text = "Shield vessel unlocked - build one at a ship crafter"
else:
lab_status.text = "Not enough ore: needs %d, you have %d" % [
GameState.UNLOCK_SHIELD_COST, GameState.rocks
]
lab_choice = 0
_refresh_lab()
return
var id: String = choice.id
if GameState.upgrade_maxed(id):
lab_status.text = "%s is already fully researched" % choice.name
return
var cost: int = GameState.upgrade_cost(id)
if not GameState.buy_upgrade(id):
lab_status.text = "Not enough ore: needs %d, you have %d" % [cost, GameState.rocks]
return
lab_status.text = "%s is now level %d" % [choice.name, GameState.upgrade_level(id)]
_refresh_lab()
func _refresh_lab() -> void:
for i in range(lab_vessel_buttons.size()):
var this_vessel: String = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_TRANSPORTER][i]
lab_vessel_buttons[i].enabled = this_vessel != lab_vessel
lab_vessel_buttons[i].queue_redraw()
lab_vessel_label.text = GameState.VESSEL_NAMES.get(lab_vessel, lab_vessel)
var options: Array = _lab_options()
if options.is_empty():
lab_list.text = ""
lab_detail.text = ""
lab_buy_button.enabled = false
return
lab_choice = clampi(lab_choice, 0, options.size() - 1)
# The whole page at a glance, with the selected line marked - so the
# player can see what else is there without stepping through it.
var lines: Array = []
for i in range(options.size()):
var o: Dictionary = options[i]
var marker: String = ">" if i == lab_choice else " "
if o.unlock:
lines.append("%s %s %d ore" % [marker, o.name, GameState.UNLOCK_SHIELD_COST])
else:
var lvl: int = GameState.upgrade_level(o.id)
var max_lvl: int = int(GameState.upgrade_def(o.id).max_level)
var price: String = "MAX" if GameState.upgrade_maxed(o.id) else "%d ore" % GameState.upgrade_cost(o.id)
lines.append("%s %s %d/%d %s" % [marker, o.name, lvl, max_lvl, price])
lab_list.text = "\n".join(lines)
var choice: Dictionary = options[lab_choice]
lab_detail.text = "%s\n%s" % [choice.name, choice.desc]
if choice.unlock:
lab_buy_button.enabled = GameState.can_afford(GameState.UNLOCK_SHIELD_COST)
else:
lab_buy_button.enabled = GameState.can_buy_upgrade(choice.id)
lab_buy_button.queue_redraw()
func _on_craft_shield() -> void:
if craft_building_id == 0:
_say("No crafter selected - reopen this panel from the building")
return
if not GameState.shield_vessel_unlocked():
_say("Shield vessels must be unlocked at a ship lab first")
return
GameState.queue_order(craft_building_id, GameState.VESSEL_SHIELD)
_report_order(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:
return
transfer_building_id = current_building.building_id
transfer_menu.visible = true
transfer_status.text = ""
_refresh_transfer()
func _on_transfer(to_building: bool, amount: int) -> void:
if transfer_building_id == 0:
transfer_status.text = "No building selected - reopen this panel from the building"
return
var moved: int = 0
if to_building:
moved = GameState.transfer_to_building(transfer_building_id, amount)
else:
moved = GameState.transfer_to_fleet(transfer_building_id, amount)
# 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"
else:
transfer_status.text = "%s has no ore to load" % _transfer_place_name()
elif to_building:
transfer_status.text = "Moved %d ore into %s" % [moved, _transfer_place_name()]
else:
transfer_status.text = "Loaded %d ore into the fleet" % moved
_refresh_transfer()
func _transfer_place_name() -> String:
var b: Dictionary = GameState.find_building(transfer_building_id)
if b.is_empty():
return "this building"
if b.type == GameState.BUILDING_MOTHERSHIP:
# Worth naming as the bank rather than as a structure: this is the
# one building where unloading makes the ore spendable everywhere.
return "the bank"
return "this building"
func _refresh_transfer() -> void:
for bar in resource_bars:
bar.building_id = transfer_building_id
var hold: int = GameState.fleet_cargo
var here: int = GameState.building_holdings(transfer_building_id)
var batch: int = GameState.CARGO_LOAD_BATCH
to_building_label.text = "Unload into %s" % _transfer_place_name()
to_fleet_label.text = "Load from %s" % _transfer_place_name()
# The batch buttons say what they would actually move, not the nominal
# batch size: offering "25" when the hold has 4 in it is a promise the
# press cannot keep.
to_building_batch_label.text = "%d" % mini(batch, hold)
to_fleet_batch_label.text = "%d" % mini(batch, here)
to_building_batch_button.enabled = hold > 0
to_building_all_button.enabled = hold > 0
to_fleet_batch_button.enabled = here > 0
to_fleet_all_button.enabled = here > 0
for button in [to_building_batch_button, to_building_all_button,
to_fleet_batch_button, to_fleet_all_button]:
button.queue_redraw()
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]
slot.icon = IconButton.Icon.TRANSPORT_HUB if order.product == GameState.PRODUCT_TRANSPORTER \
else IconButton.Icon.SHIP
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_NAMES.get(head.product, head.product),
int(round(float(head.progress) * 100.0)),
]
else:
queue_detail.text = "%s waiting for ore\nTap to cancel" % \
GameState.VESSEL_NAMES.get(head.product, head.product)
func _vessel_colour(product: String) -> Color:
match product:
GameState.VESSEL_SHIELD:
return Color(0.45, 0.8, 1.0)
GameState.PRODUCT_TRANSPORTER:
return Color(0.95, 0.55, 0.85)
_:
return 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_NAMES.get(orders[index].product, 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, " - ore refunded here" if refunded else ""])
_refresh_queue()
# --- hangar (#23) ---------------------------------------------------------
func _on_hangar_pressed() -> void:
hangar_choice = 0
hangar_status.text = ""
hangar_menu.visible = true
_refresh_hangar()
func _on_hangar_next() -> void:
if GameState.stored_vessels.is_empty():
return
hangar_choice = (hangar_choice + 1) % GameState.stored_vessels.size()
_refresh_hangar()
func _on_hangar_crew() -> void:
if GameState.stored_vessels.is_empty():
hangar_status.text = "Nothing built yet - order vessels at a ship crafter"
return
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 product: String = GameState.claim_stored_vessel(hangar_choice)
if product == "" or not fleet.add_ship(product):
hangar_status.text = "Could not crew that vessel"
return
GameState.take_stored_vessel(hangar_choice)
hangar_status.text = "%s joined the fleet" % GameState.VESSEL_NAMES.get(product, product)
hangar_choice = 0
_refresh_hangar()
func _refresh_hangar() -> void:
var stored: Array = GameState.stored_vessels
if stored.is_empty():
hangar_list.text = "No vessels waiting.\n\nOrder them at a ship crafter - finished vessels are delivered here."
hangar_crew_button.enabled = false
hangar_next_button.enabled = false
else:
hangar_choice = clampi(hangar_choice, 0, stored.size() - 1)
var lines: Array = []
for i in range(stored.size()):
var marker: String = ">" if i == hangar_choice else " "
lines.append("%s %s" % [marker, GameState.VESSEL_NAMES.get(stored[i], stored[i])])
hangar_list.text = "\n".join(lines)
hangar_crew_button.enabled = fleet.ships.size() < GameState.max_fleet_size()
hangar_next_button.enabled = stored.size() > 1
hangar_crew_button.queue_redraw()
hangar_next_button.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()