Compose buildings from definitions and capabilities

This commit is contained in:
Loïc Wisniewski 2026-08-14 21:44:28 +02:00
commit 0504b24d8d
46 changed files with 1091 additions and 878 deletions

View file

@ -1,5 +1,7 @@
extends Node2D
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.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
@ -22,14 +24,8 @@ extends Node2D
@onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton")
@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"),
@ -78,20 +74,7 @@ extends Node2D
get_node("../UI/HangarMenu/FleetShieldLabel"),
get_node("../UI/HangarMenu/FleetTransporterLabel"),
]
@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")],
]
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
@ -148,41 +131,6 @@ var lab_building_id: int = 0
@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 = ""
@ -210,12 +158,7 @@ var context_actions_expanded: bool = false
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)
_configure_build_menu_entries()
craft_shield_button.pressed.connect(_on_craft_shield)
lab_close_button.pressed.connect(func(): lab_menu.visible = false)
lab_buy_button.pressed.connect(_on_lab_buy)
@ -267,6 +210,21 @@ func _ready() -> void:
_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 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:
@ -318,9 +276,7 @@ func _process(delta: float) -> void:
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()
_refresh_build_option_states()
if craft_menu.visible:
# Live, so a delivery arriving while the menu is open visibly works
@ -359,10 +315,7 @@ func _process(delta: float) -> void:
current_building = pending_building
context_actions_expanded = false
# 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
hangar_button.visible = false
# 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.
@ -371,7 +324,7 @@ func _process(delta: float) -> void:
action_button.visible = current_building != null
if current_building != null:
var actions: Array = _context_action_buttons()
building_action_button.icon = _action_icon_for(current_building.building_type)
building_action_button.icon = int(current_building.definition.action_icon)
building_action_button.queue_redraw()
action_button.icon = building_action_button.icon if actions.size() == 1 else IconButton.Icon.ACTION_MENU
action_button.queue_redraw()
@ -403,13 +356,20 @@ func _layout_context_buttons() -> void:
func _context_action_buttons() -> Array:
if current_building == null:
return []
var actions: Array = [transfer_button]
if current_building.building_type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER,
GameState.BUILDING_SHIP_LAB, GameState.BUILDING_COMMAND_CENTER]:
actions.push_front(building_action_button)
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
actions.append(hangar_button)
return actions
var buttons: Array = []
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)
return buttons
func _button_for_building_action(action: String) -> IconButton:
if action == "transfer":
return transfer_button
if action == "hangar":
return hangar_button
return building_action_button
func _on_context_action_pressed() -> void:
@ -433,20 +393,6 @@ func _collapse_context_actions() -> void:
_layout_context_buttons()
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
@ -465,11 +411,12 @@ func _on_action_pressed() -> void:
for bar in resource_bars:
bar.building_id = current_building.building_id
if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
var action: String = current_building.primary_action()
if action == "research":
skill_menu.visible = true
skill_tree.reset_scroll()
_refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
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
@ -478,11 +425,11 @@ func _on_action_pressed() -> void:
craft_building_id = current_building.building_id
craft_menu.visible = true
_refresh_craft_status()
elif current_building.building_type == GameState.BUILDING_SHIP_LAB:
elif action == "lab":
lab_building_id = current_building.building_id
lab_menu.visible = true
_refresh_lab()
elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER:
elif action == "formation":
formation_menu.visible = true
formation_editor.queue_redraw()
@ -551,8 +498,7 @@ func _refresh_build_status() -> void:
# 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)
_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.
@ -562,16 +508,14 @@ func _refresh_build_status() -> void:
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 _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":
enabled = enabled and _planet_under_fleet() != null
entry[1].enabled = enabled
entry[1].queue_redraw()
func _close_build_menu() -> void:
@ -582,40 +526,17 @@ func _close_build_menu() -> void:
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
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 info: Dictionary = BUILDING_INFO[type]
var cost: int = cost_of(type)
var lines: Array = [info.body]
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
@ -626,7 +547,7 @@ func _show_building_info(type: String) -> void:
# 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:
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.")
@ -635,15 +556,15 @@ func _show_building_info(type: String) -> void:
lines.append("%s already has an extractor." % planet.planet_name)
can_build = false
else:
lines.append("Will mine %s from %s." % ["gas" if planet.resource_type == "gas" else "ore", planet.planet_name])
elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
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.")
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_title.text = definition.display_name
info_message.text = "\n\n".join(lines)
info_cost.amount = cost
info_build_button.visible = can_build
@ -668,28 +589,31 @@ func _confirm_build() -> void:
# 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 type == GameState.BUILDING_EXTRACTOR:
if definition.placement_mode == "planet":
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]
if not GameState.spend(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
return
GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type)
build_menu.visible = false
return
if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
if not GameState.can_afford(GameState.MOTHERSHIP_COST):
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)" % [
GameState.MOTHERSHIP_COST, GameState.rocks
definition.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)
GameState.remove_buildings_of_type(type)
building_field.rebuild_all()
_try_place(type, cost_of(type))