From 06081c41b06be2549487e1a864266eb26afb7e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Wed, 12 Aug 2026 23:08:43 +0200 Subject: [PATCH] Improve ship crafter and hangar controls --- BuildController.gd | 149 +++++++++++++++++++------------ Fleet.gd | 17 ++++ Main.tscn | 152 ++++++++++++++++++++++++++++++++ tests/test_hangar_management.gd | 86 ++++++++++++++++++ 4 files changed, 348 insertions(+), 56 deletions(-) create mode 100644 tests/test_hangar_management.gd diff --git a/BuildController.gd b/BuildController.gd index 725dedd..7c6d3b1 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -49,14 +49,28 @@ extends Node2D @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 hangar_available_slots: Array = [ + get_node("../UI/HangarMenu/AvailableInterceptor"), + get_node("../UI/HangarMenu/AvailableShield"), + get_node("../UI/HangarMenu/AvailableTransporter"), +] +@onready var hangar_available_labels: Array = [ + get_node("../UI/HangarMenu/AvailableInterceptorLabel"), + get_node("../UI/HangarMenu/AvailableShieldLabel"), + get_node("../UI/HangarMenu/AvailableTransporterLabel"), +] +@onready var hangar_fleet_slots: Array = [ + get_node("../UI/HangarMenu/FleetInterceptor"), + get_node("../UI/HangarMenu/FleetShield"), + get_node("../UI/HangarMenu/FleetTransporter"), +] +@onready var hangar_fleet_labels: Array = [ + get_node("../UI/HangarMenu/FleetInterceptorLabel"), + 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")], @@ -91,6 +105,9 @@ var lab_choice: int = 0 @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") +@onready var craft_interceptor_label: Label = get_node("../UI/CraftMenu/InterceptorLabel") +@onready var craft_transporter_label: Label = get_node("../UI/CraftMenu/TransporterLabel") +@onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel") # --- the transfer panel (#24) --------------------------------------------- # Ore used to be moved from inside whichever panel happened to be open: "load @@ -216,8 +233,9 @@ func _ready() -> void: 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(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) @@ -245,6 +263,7 @@ func _ready() -> void: transfer_button.visible = false hangar_button.visible = false hangar_menu.visible = false + _layout_context_buttons() # True while a building prompt or panel owns the shared action slot, so the @@ -340,6 +359,20 @@ func _process(delta: float) -> void: if current_building != null: action_button.icon = _action_icon_for(current_building.building_type) action_button.queue_redraw() + _layout_context_buttons() + + +func _layout_context_buttons() -> void: + # Every contextual action uses a distinct, fixed berth above Build. This + # replaces the old hand-picked offsets where Hangar overlapped Action at a + # mothership (#25). + var buttons: Array = [action_button, transfer_button] + if hangar_button.visible: + buttons.append(hangar_button) + for i in range(buttons.size()): + var button: Control = buttons[i] + button.offset_top = -720 + i * 164 + button.offset_bottom = button.offset_top + 148 func _action_icon_for(type: String) -> int: @@ -672,16 +705,13 @@ func _say(message: String) -> void: 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() - ] + # 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_interceptor_label.text = "Interceptor\n%d ore" % GameState.SHIP_COST + craft_transporter_label.text = "Transporter\n%d ore" % GameState.TRANSPORTER_COST + craft_shield_label.text = "Shield Vessel\n%d ore" % GameState.SHIELD_COST + craft_status.text = "" # 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() \ @@ -698,11 +728,6 @@ func _refresh_craft_status() -> void: # 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: @@ -995,55 +1020,67 @@ func _on_queue_slot(index: int) -> void: # --- 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 +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 product: String = GameState.claim_stored_vessel(hangar_choice) - if product == "" or not fleet.add_ship(product): + var stored_index: int = GameState.stored_vessels.find(type) + if stored_index < 0: + hangar_status.text = "No %s available" % GameState.VESSEL_NAMES.get(type, type) + return + if not fleet.add_ship(GameState.take_stored_vessel(stored_index)): 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 + hangar_status.text = "%s joined the fleet" % GameState.VESSEL_NAMES.get(type, 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_NAMES.get(type, 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_NAMES.get(stored, stored) + _refresh_hangar() + + +func _hangar_types() -> Array: + return [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER] + + 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() + var types: Array = _hangar_types() + for i in range(types.size()): + var type: String = types[i] + 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" % [GameState.VESSEL_NAMES.get(type, type), available] + hangar_fleet_labels[i].text = "%s\n%d in fleet" % [GameState.VESSEL_NAMES.get(type, type), deployed] + 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: diff --git a/Fleet.gd b/Fleet.gd index 14497f3..b108dd1 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -92,6 +92,23 @@ func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool: return true +func store_ship(index: int) -> String: + # A fleet always keeps one ship deployed. Removing the last one would trip + # the wiped-out-fleet safety net on the next frame and immediately spawn a + # replacement, which is the opposite of storing it at the mothership. + if ships.size() <= 1 or index < 0 or index >= ships.size(): + return "" + var ship: Node = ships[index] + if not is_instance_valid(ship): + return "" + var vessel_type: String = String(ship.vessel_type) + ships.remove_at(index) + formation_offsets.remove_at(index) + ship.queue_free() + apply_formation() + return vessel_type + + func apply_formation() -> void: # Re-seats the EXISTING ships into the current GameState.formation. # Without this, editing the layout at a command centre (or pivoting it) diff --git a/Main.tscn b/Main.tscn index b1a9f02..c5749f0 100644 --- a/Main.tscn +++ b/Main.tscn @@ -665,6 +665,7 @@ text = "Hangar" horizontal_alignment = 1 [node name="ListLabel" type="Label" parent="UI/HangarMenu"] +visible = false mouse_filter = 2 anchor_right = 1.0 anchor_bottom = 1.0 @@ -692,6 +693,7 @@ horizontal_alignment = 1 text = "" [node name="NextButton" type="Control" parent="UI/HangarMenu"] +visible = false anchor_left = 0.5 anchor_top = 1.0 anchor_right = 0.5 @@ -705,6 +707,7 @@ icon = 6 glow_color = Color(0.55, 0.95, 0.9, 1) [node name="CrewButton" type="Control" parent="UI/HangarMenu"] +visible = false anchor_left = 0.5 anchor_top = 1.0 anchor_right = 0.5 @@ -718,6 +721,7 @@ icon = 2 glow_color = Color(0.45, 0.9, 0.55, 1) [node name="HintLabel" type="Label" parent="UI/HangarMenu"] +visible = false mouse_filter = 2 anchor_right = 1.0 anchor_top = 1.0 @@ -731,6 +735,154 @@ autowrap_mode = 2 horizontal_alignment = 1 text = "Next Crew" +[node name="AvailableTitle" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_top = 190 +offset_bottom = 240 +theme_override_font_sizes/font_size = 28 +text = "AVAILABLE — TAP TO CREW" +horizontal_alignment = 1 + +[node name="AvailableInterceptor" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -250 +offset_right = -130 +offset_top = 255 +offset_bottom = 375 +script = ExtResource("10") +icon = 2 + +[node name="AvailableInterceptorLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -300 +offset_right = -80 +offset_top = 380 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="AvailableShield" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -60 +offset_right = 60 +offset_top = 255 +offset_bottom = 375 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.45, 0.8, 1, 1) + +[node name="AvailableShieldLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -110 +offset_right = 110 +offset_top = 380 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="AvailableTransporter" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 130 +offset_right = 250 +offset_top = 255 +offset_bottom = 375 +script = ExtResource("10") +icon = 12 +glow_color = Color(0.95, 0.55, 0.85, 1) + +[node name="AvailableTransporterLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 80 +offset_right = 300 +offset_top = 380 +offset_bottom = 460 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="FleetTitle" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_top = 500 +offset_bottom = 550 +theme_override_font_sizes/font_size = 28 +text = "FLEET — TAP TO STORE" +horizontal_alignment = 1 + +[node name="FleetInterceptor" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -250 +offset_right = -130 +offset_top = 565 +offset_bottom = 685 +script = ExtResource("10") +icon = 2 + +[node name="FleetInterceptorLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -300 +offset_right = -80 +offset_top = 690 +offset_bottom = 770 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="FleetShield" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -60 +offset_right = 60 +offset_top = 565 +offset_bottom = 685 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.45, 0.8, 1, 1) + +[node name="FleetShieldLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -110 +offset_right = 110 +offset_top = 690 +offset_bottom = 770 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + +[node name="FleetTransporter" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 130 +offset_right = 250 +offset_top = 565 +offset_bottom = 685 +script = ExtResource("10") +icon = 12 +glow_color = Color(0.95, 0.55, 0.85, 1) + +[node name="FleetTransporterLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = 80 +offset_right = 300 +offset_top = 690 +offset_bottom = 770 +theme_override_font_sizes/font_size = 22 +horizontal_alignment = 1 + [node name="CloseButton" type="Control" parent="UI/HangarMenu"] anchor_left = 1.0 anchor_right = 1.0 diff --git a/tests/test_hangar_management.gd b/tests/test_hangar_management.gd new file mode 100644 index 0000000..8ad6cd3 --- /dev/null +++ b/tests/test_hangar_management.gd @@ -0,0 +1,86 @@ +extends SceneTree + +# Forgejo #23 + #25: the mothership hangar presents vessels by type, tapping +# an available type crews one, tapping a fleet type stores one, and contextual +# world buttons each get their own touch berth. + +var game_state: Node +var scene: Node +var fleet: Node +var controller: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.rocks = 100 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + controller = scene.get_node("BuildController") + + +func _process(delta: float) -> bool: + elapsed += delta + if stage == 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + fleet.spawn_ships(1, Vector2.ZERO) + fleet.add_ship(GameState.VESSEL_SHIELD) + game_state.stored_vessels = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD] + controller._on_hangar_pressed() + elapsed = 0.0 + stage = 1 + return false + if stage == 1: + if elapsed < 0.2: + return false + # Available cards name the vessel and count what can be crewed. + var available_label: Label = controller.hangar_available_labels[0] + if "Interceptor" not in available_label.text or "1 available" not in available_label.text: + _fail("available interceptor card says '%s'" % available_label.text) + return true + controller._on_hangar_crew_type(0) + if fleet.ships.size() != 3 or game_state.stored_vessels.has(GameState.VESSEL_INTERCEPTOR): + _fail("tapping an available interceptor did not crew it") + return true + controller._on_hangar_store_type(1) + if fleet.ships.size() != 2 or not game_state.stored_vessels.has(GameState.VESSEL_SHIELD): + _fail("tapping the shield fleet card did not store it") + return true + # The safety rule prevents an empty deployed fleet. + controller._on_hangar_store_type(0) + controller._on_hangar_store_type(0) + if fleet.ships.size() != 1: + _fail("hangar allowed the last deployed ship to be stored") + return true + + # #25: all three contextual controls have non-overlapping vertical + # rectangles when the mothership exposes Action, Transfer and Hangar. + controller.action_button.visible = true + controller.transfer_button.visible = true + controller.hangar_button.visible = true + controller._layout_context_buttons() + var buttons: Array = [controller.action_button, controller.transfer_button, controller.hangar_button] + for i in range(buttons.size() - 1): + if buttons[i].offset_bottom > buttons[i + 1].offset_top: + _fail("context buttons %d and %d overlap" % [i, i + 1]) + return true + print("hangar crews and stores typed vessels; contextual buttons have separate berths") + print("TEST_HANGAR_MANAGEMENT: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> void: + print("TEST_HANGAR_MANAGEMENT: FAIL (%s)" % message) + quit(1)