Ship Lab: per-vessel research, and the shield vessel

Forgejo #5. A new building gated behind a "Vessel Science" research
node. Its own screen upgrades each vessel type - weapon, movement,
shield and freight - and unlocks the shield vessel, which the ship
crafter can then build.

Vessel research is deliberately NOT more nodes in the mothership tree:
that tree gates what may be built at all and is a strategic spine, while
these are repeatable tuning on vessels already owned. Levels cost
base * (level + 1), so the third point in something is not the price of
the first. Upgrades are read live rather than baked in at build time, so
researching one improves the ships already flying.

The shield is personal, not a bubble: it absorbs a hit that would
destroy the vessel - ships have no health, contact kills - and is then
down until it cycles. Its ring is a NeonPiece child toggled with
`visible` per #10, not a redraw of the hull.

The build menu now holds six entries across three rows, which the #21
repacking already handles.

Enemies still have no weapon, so "blocks shots" has nothing to block
yet; that is filed separately rather than widened into this ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 23:31:49 +02:00
commit 1cbae8ff70
13 changed files with 876 additions and 5 deletions

View file

@ -28,6 +28,20 @@ extends Node2D
@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 build_menu_entries: Array = [
[GameState.BUILDING_MOTHERSHIP, build_mothership_button,
get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")],
@ -39,7 +53,16 @@ extends Node2D
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")
@ -92,6 +115,10 @@ const BUILDING_INFO: Dictionary = {
"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.",
@ -127,6 +154,13 @@ func _ready() -> void:
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)
@ -151,6 +185,7 @@ func _ready() -> void:
craft_menu.visible = false
formation_menu.visible = false
skill_menu.visible = false
lab_menu.visible = false
action_button.visible = false
@ -183,7 +218,7 @@ func _unhandled_input(event: InputEvent) -> void:
formation_editor.handle_drag(event)
get_viewport().set_input_as_handled()
return
if build_menu.visible or craft_menu.visible or world_map.visible:
if build_menu.visible or craft_menu.visible or world_map.visible or lab_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()
@ -205,7 +240,7 @@ func _process(delta: float) -> void:
_refresh_craft_status()
if world_map.visible or build_menu.visible or craft_menu.visible \
or formation_menu.visible or skill_menu.visible:
or formation_menu.visible or skill_menu.visible or lab_menu.visible:
current_building = null
pending_building = null
action_button.visible = false
@ -237,6 +272,8 @@ func _action_icon_for(type: String) -> int:
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
_:
@ -262,6 +299,9 @@ func _on_action_pressed() -> void:
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
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()
@ -354,6 +394,10 @@ func _on_place_command_center() -> void:
_show_building_info(GameState.BUILDING_COMMAND_CENTER)
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)
@ -368,6 +412,8 @@ 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:
@ -547,6 +593,8 @@ func _refresh_craft_status() -> void:
and GameState.can_station_transporter()
craft_transporter_button.queue_redraw()
craft_shield_button.enabled = GameState.shield_vessel_unlocked()
craft_shield_button.queue_redraw()
craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo
# Deliberately left ENABLED with an empty hold: a greyed-out button that
# cannot say why is exactly the dead-button problem this panel already
@ -645,6 +693,118 @@ func _on_craft_transporter() -> void:
_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:
return
if not GameState.shield_vessel_unlocked():
_say("Shield vessels must be unlocked at a ship lab first")
return
if fleet.ships.size() + GameState.vessels_owed.size() \
+ GameState.pending_orders(craft_building_id) >= GameState.max_fleet_size():
_say("Fleet is full (%d slots)" % GameState.max_fleet_size())
return
GameState.queue_order(craft_building_id, GameState.VESSEL_SHIELD)
_report_order(GameState.VESSEL_SHIELD)
func _on_formation_changed() -> void:
fleet.apply_formation()