Refactor gameplay menus into owned scenes

This commit is contained in:
Loïc Wisniewski 2026-08-31 02:18:08 +02:00
commit a17fe88197
50 changed files with 3449 additions and 3619 deletions

View file

@ -46,9 +46,10 @@ func _initialize() -> void:
func _shown() -> Array:
var out: Array = []
for entry in controller.build_menu_entries:
if entry[1].visible:
out.append(entry)
for entry in controller.build_menu.entries:
if entry.button.visible:
out.append([entry.definition.definition_id, entry.button,
entry.label, entry.cost, entry.definition])
return out

View file

@ -105,7 +105,7 @@ func _process(delta: float) -> bool:
# --- D. locked state in the crafter UI -------------------------
controller.craft_building_id = crafter_id
controller._refresh_craft_status()
if controller.craft_vessel_buttons[FLEET_UI_INDEX].enabled:
if controller.craft_menu.vessel_buttons[FLEET_UI_INDEX].enabled:
return _fail("a locked carrier shows enabled in the crafter menu")
stage = 1
@ -126,8 +126,8 @@ func _process(delta: float) -> bool:
# --- D. unlocked state in the crafter UI ------------------------
controller._refresh_craft_status()
var craft_button: IconButton = controller.craft_vessel_buttons[FLEET_UI_INDEX]
var craft_label: Label = controller.craft_vessel_labels[FLEET_UI_INDEX]
var craft_button: IconButton = controller.craft_menu.vessel_buttons[FLEET_UI_INDEX]
var craft_label: Label = controller.craft_menu.vessel_labels[FLEET_UI_INDEX]
if not craft_button.enabled:
return _fail("an unlocked carrier still shows disabled in the crafter menu")
if craft_button.icon != definition.icon or craft_button.glow_color != definition.glow_color:
@ -148,8 +148,8 @@ func _process(delta: float) -> bool:
# --- D. hangar display -------------------------------------------
controller._refresh_hangar()
var available_slot: IconButton = controller.hangar_available_slots[FLEET_UI_INDEX]
var available_label: Label = controller.hangar_available_labels[FLEET_UI_INDEX]
var available_slot: IconButton = controller.hangar_menu.available_slots[FLEET_UI_INDEX]
var available_label: Label = controller.hangar_menu.available_labels[FLEET_UI_INDEX]
if available_slot.icon != definition.icon or available_slot.glow_color != definition.glow_color:
return _fail("hangar carrier slot does not match its definition's icon/colour")
var expected_available: String = "%s\n1 available" % definition.display_name
@ -177,8 +177,8 @@ func _process(delta: float) -> bool:
# --- D. ship lab display -------------------------------------------
controller.lab_vessel = GameState.VESSEL_CARRIER
controller._refresh_lab()
var lab_button: IconButton = controller.lab_vessel_buttons[LAB_UI_INDEX]
var lab_label: Label = controller.lab_vessel_labels[LAB_UI_INDEX]
var lab_button: IconButton = controller.lab_menu.vessel_buttons[LAB_UI_INDEX]
var lab_label: Label = controller.lab_menu.vessel_labels[LAB_UI_INDEX]
if lab_button.icon != definition.icon or lab_button.glow_color != definition.glow_color:
return _fail("ship lab carrier slot does not match its definition's icon/colour")
if lab_label.text != definition.display_name:

View file

@ -67,7 +67,7 @@ func _tap(button: Control) -> void:
func _first_line() -> String:
return controller.craft_status.text.split("\n")[0]
return controller.craft_menu.status.text.split("\n")[0]
func _process(delta: float) -> bool:

View file

@ -55,7 +55,7 @@ func _process(delta: float) -> bool:
or controller.landing_button.visible:
return _fail("landing was not folded into the shared action control")
controller._on_transfer_pressed()
if not controller.transfer_menu.visible or controller.transfer_list.extractor_index != extractor_index:
if not controller.transfer_menu.visible or controller.transfer_menu.resource_list.extractor_index != extractor_index:
return _fail("transfer panel did not bind to the extractor")
var before: int = state.fleet_resource(resource_type)
controller._on_resource_transfer(resource_type, false, 5)

View file

@ -45,7 +45,7 @@ func _process(delta: float) -> bool:
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]
var available_label: Label = controller.hangar_menu.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

View file

@ -69,34 +69,34 @@ func _process(delta: float) -> bool:
# Gas Bomber is named after a resource and always will be, and
# what #111 removed was the PRICE spelled out underneath.
var fleet_defs: Array = VesselDefinitions.fleet()
for i in range(mini(controller.craft_vessel_labels.size(), fleet_defs.size())):
var caption: String = String(controller.craft_vessel_labels[i].text)
for i in range(mini(controller.craft_menu.vessel_labels.size(), fleet_defs.size())):
var caption: String = String(controller.craft_menu.vessel_labels[i].text)
var expected: String = String(fleet_defs[i].display_name)
if caption != expected:
return _fail("a craft caption reads \"%s\", expected just \"%s\"" \
% [caption, expected])
# Captions must not sit on top of one another, which is what
# made the old single row unreadable.
for i in range(controller.craft_vessel_labels.size()):
for j in range(i + 1, controller.craft_vessel_labels.size()):
var a: Control = controller.craft_vessel_labels[i]
var b: Control = controller.craft_vessel_labels[j]
for i in range(controller.craft_menu.vessel_labels.size()):
for j in range(i + 1, controller.craft_menu.vessel_labels.size()):
var a: Control = controller.craft_menu.vessel_labels[i]
var b: Control = controller.craft_menu.vessel_labels[j]
if Rect2(a.position, a.size).intersects(Rect2(b.position, b.size)):
return _fail("craft captions %d and %d overlap" % [i, j])
print("craft captions: %d, one line each, none overlapping" \
% controller.craft_vessel_labels.size())
% controller.craft_menu.vessel_labels.size())
# --- B. and the price is really there ---------------------
if controller.craft_price_strips.size() != controller.craft_vessel_buttons.size():
if controller.craft_menu.price_tags.size() != controller.craft_menu.vessel_buttons.size():
return _fail("%d price strips for %d vessels" \
% [controller.craft_price_strips.size(),
controller.craft_vessel_buttons.size()])
% [controller.craft_menu.price_tags.size(),
controller.craft_menu.vessel_buttons.size()])
var priced: int = 0
var stacked: int = 0
for i in range(controller.craft_price_strips.size()):
for i in range(controller.craft_menu.price_tags.size()):
var definition: Resource = VesselDefinitions.fleet()[i]
var costs: Dictionary = game_state.product_costs(definition.definition_id)
var strip: Control = controller.craft_price_strips[i]
var strip: Control = controller.craft_menu.price_tags[i]
if costs.is_empty():
continue
if strip.is_free():
@ -122,7 +122,7 @@ func _process(delta: float) -> bool:
% [definition.definition_id, box.position.x, box.end.x, strip.size.x])
# ...and the price sits with the button it is about, not
# adrift below the caption.
var button: Control = controller.craft_vessel_buttons[i]
var button: Control = controller.craft_menu.vessel_buttons[i]
var gap: float = strip.position.y - (button.position.y + button.size.y)
if gap > 80.0:
return _fail("%s's price sits %.0fpx below its button" \
@ -135,10 +135,10 @@ func _process(delta: float) -> bool:
% [priced, stacked])
# --- F. #147 - and the rows do not run into each other -------
for i in range(controller.craft_vessel_buttons.size()):
for j in range(i + 1, controller.craft_vessel_buttons.size()):
for part in [controller.craft_vessel_buttons,
controller.craft_vessel_labels, controller.craft_price_strips]:
for i in range(controller.craft_menu.vessel_buttons.size()):
for j in range(i + 1, controller.craft_menu.vessel_buttons.size()):
for part in [controller.craft_menu.vessel_buttons,
controller.craft_menu.vessel_labels, controller.craft_menu.price_tags]:
if i >= part.size() or j >= part.size():
continue
var a: Control = part[i]
@ -146,9 +146,9 @@ func _process(delta: float) -> bool:
if Rect2(a.position, a.size).intersects(Rect2(b.position, b.size)):
return _fail("craft entries %d and %d overlap" % [i, j])
# A price must not reach the button of the row below it.
var mine: Control = controller.craft_price_strips[i]
for k in range(controller.craft_vessel_buttons.size()):
var other: Control = controller.craft_vessel_buttons[k]
var mine: Control = controller.craft_menu.price_tags[i]
for k in range(controller.craft_menu.vessel_buttons.size()):
var other: Control = controller.craft_menu.vessel_buttons[k]
if other.position.y > mine.position.y \
and Rect2(mine.position, mine.size).intersects(
Rect2(other.position, other.size)):
@ -157,26 +157,26 @@ func _process(delta: float) -> bool:
# --- C. the hangar ---------------------------------------
controller._layout_hangar()
if controller.hangar_divider_y <= 0.0:
if controller.hangar_menu.divider_y <= 0.0:
return _fail("the hangar laid out with no divider between its halves")
var available_bottom: float = 0.0
var fleet_top: float = 1.0e9
for label in controller.hangar_available_labels:
for label in controller.hangar_menu.available_labels:
available_bottom = maxf(available_bottom, label.position.y + label.size.y)
for slot in controller.hangar_fleet_slots:
for slot in controller.hangar_menu.fleet_slots:
fleet_top = minf(fleet_top, slot.position.y)
if not (controller.hangar_divider_y > available_bottom - 40.0
and controller.hangar_divider_y < fleet_top):
if not (controller.hangar_menu.divider_y > available_bottom - 40.0
and controller.hangar_menu.divider_y < fleet_top):
return _fail("the divider at %.0f is not between the two grids (%.0f / %.0f)" \
% [controller.hangar_divider_y, available_bottom, fleet_top])
% [controller.hangar_menu.divider_y, available_bottom, fleet_top])
# Two lines of caption. The font is 24-ish, so anything under
# about 44 cannot show a second line at all.
var caption_height: float = controller.hangar_available_labels[0].size.y
var caption_height: float = controller.hangar_menu.available_labels[0].size.y
if caption_height < 44.0:
return _fail("hangar captions are %.0fpx tall - a two-word name is clipped" \
% caption_height)
print("hangar: %.0fpx captions, divider at %.0f between %.0f and %.0f" \
% [caption_height, controller.hangar_divider_y, available_bottom, fleet_top])
% [caption_height, controller.hangar_menu.divider_y, available_bottom, fleet_top])
# --- D. #143 - and the caption stays in its box --------------
# The caption is two lines before autowrap and three after it in
@ -185,8 +185,8 @@ func _process(delta: float) -> bool:
# the box now, and the box clips, so nothing can reach the next
# row however the vessel names grow.
controller._refresh_hangar()
for pair in [[controller.hangar_available_labels, controller.hangar_available_slots],
[controller.hangar_fleet_labels, controller.hangar_fleet_slots]]:
for pair in [[controller.hangar_menu.available_labels, controller.hangar_menu.available_slots],
[controller.hangar_menu.fleet_labels, controller.hangar_menu.fleet_slots]]:
for i in range(pair[0].size()):
var caption: Label = pair[0][i]
if not caption.clip_text:
@ -205,7 +205,7 @@ func _process(delta: float) -> bool:
and Rect2(caption.position, caption.size).intersects(below):
return _fail("caption \"%s\" runs into the icon below it" \
% caption.text.replace("\n", " / "))
var probe: Label = controller.hangar_available_labels[0]
var probe: Label = controller.hangar_menu.available_labels[0]
print("captions fitted to %dpx and clipped; \"%s\" fits its %.0fpx box" \
% [probe.get_theme_font_size("font_size"),
probe.text.replace("\n", " / "), probe.size.y])

View file

@ -107,12 +107,12 @@ func _process(delta: float) -> bool:
# --- B. one price widget everywhere ----------------------
var tags: Array = []
for entry in controller.build_menu_entries:
tags.append(entry[3])
tags.append_array(controller.craft_price_strips)
tags.append_array(controller.lab_price_tags)
tags.append(controller.info_cost)
tags.append(controller.skill_cost)
for entry in controller.build_menu.entries:
tags.append(entry.cost)
tags.append_array(controller.craft_menu.price_tags)
tags.append_array(controller.lab_menu.price_tags)
tags.append(controller.build_menu.info_cost)
tags.append(controller.skill_menu.cost)
if tags.size() < 15:
return _fail("only %d price widgets found - the menus cannot all be covered" % tags.size())
for tag in tags:
@ -124,7 +124,7 @@ func _process(delta: float) -> bool:
# --- C. have over cost, smaller, red when short ----------
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 3)
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0)
var probe: Control = controller.info_cost
var probe: Control = controller.build_menu.info_cost
probe.show_price({GameState.RESOURCE_ORE: 25})
if probe.priced(GameState.RESOURCE_ORE) != 25:
return _fail("the tag does not carry the price it was given")
@ -165,8 +165,8 @@ func _process(delta: float) -> bool:
if String(node.desc).length() > longest.length():
longest = String(node.desc)
controller._refresh_skill_menu(String(node.id))
var detail: Rect2 = _rect(controller.skill_detail)
var price: Rect2 = _rect(controller.skill_cost)
var detail: Rect2 = _rect(controller.skill_menu.detail)
var price: Rect2 = _rect(controller.skill_menu.cost)
if detail.intersects(price):
return _fail("the explanation (%s) overlaps the price (%s)" % [detail, price])
if detail.position.x >= price.position.x:
@ -179,11 +179,11 @@ func _process(delta: float) -> bool:
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 0)
game_state.unlocked.clear()
controller._refresh_skill_menu("shipyards")
if controller.skill_cost.is_affordable():
if controller.skill_menu.cost.is_affordable():
return _fail("a node reads as affordable with no ore at all")
if controller.skill_status.text.to_lower().contains("not enough"):
if controller.skill_menu.status.text.to_lower().contains("not enough"):
return _fail("the shortfall is still written out as well: '%s'" \
% controller.skill_status.text)
% controller.skill_menu.status.text)
print("the shortfall is shown once, on the price, and not repeated in words")
print("TEST_MENU_PRICE_UNIFICATION: PASS")

View file

@ -0,0 +1,42 @@
extends SceneTree
const MENUS: Array[String] = [
"BuildMenu", "CraftMenu", "LabMenu", "TransferMenu", "HangarMenu",
"FormationMenu", "SkillMenu", "PlanningMenu", "GarrisonMenu",
]
func _initialize() -> void:
call_deferred("_verify")
func _verify() -> void:
var packed: PackedScene = load("res://world/Main.tscn")
if packed == null:
return _fail("Main.tscn did not load")
var main: Node = packed.instantiate()
root.add_child(main)
for menu_name in MENUS:
var menu: Node = main.get_node_or_null("UI/%s" % menu_name)
if menu == null:
return _fail("%s is missing" % menu_name)
var expected: String = "res://ui/menus/%s.tscn" % menu_name
if menu.scene_file_path != expected:
return _fail("%s is inline in Main.tscn instead of instancing %s" \
% [menu_name, expected])
if menu.get_script() == null:
return _fail("%s has no owning script" % menu_name)
var source: String = FileAccess.get_file_as_string(
"res://controllers/BuildController.gd")
for menu_name in MENUS:
if "../UI/%s/" % menu_name in source:
return _fail("BuildController reaches into %s children" % menu_name)
print("%d menu scenes own their controls; BuildController uses root APIs" % MENUS.size())
print("TEST_MENU_SCENE_BOUNDARIES: PASS")
quit(0)
func _fail(message: String) -> void:
print("TEST_MENU_SCENE_BOUNDARIES: FAIL (%s)" % message)
quit(1)

View file

@ -0,0 +1 @@
uid://bufrmk7en8qxd

View file

@ -253,7 +253,7 @@ func _process(delta: float) -> bool:
if game_state.buildings.size() != before + 1:
return _fail("building a mothership from the fleet's own hold did not place one (%d -> %d): %s" \
% [before, game_state.buildings.size(), build_controller.build_status.text])
% [before, game_state.buildings.size(), build_controller.build_menu.status.text])
# Checked as a TOTAL, not as the hold alone: a mothership standing
# where the fleet is starts taking delivery of the hold on the
# very next tick, so ore legitimately moves from hold to bank

View file

@ -154,13 +154,13 @@ func _process(delta: float) -> bool:
controller._refresh_skill_menu(second)
# #134: the refusal is shown on the price rather than written out in a
# sentence below it - what you hold over what it costs, in red.
if controller.skill_cost.is_affordable():
if controller.skill_menu.cost.is_affordable():
return _fail("a node the player cannot pay for reads as affordable")
if not controller.skill_cost.is_short(GameState.RESOURCE_ORE):
if not controller.skill_menu.cost.is_short(GameState.RESOURCE_ORE):
return _fail("the ore the player does not have is not marked short")
print("unaffordable node refused, and its price says so: %d/%d ore" \
% [controller.skill_cost.held(GameState.RESOURCE_ORE),
controller.skill_cost.priced(GameState.RESOURCE_ORE)])
% [controller.skill_menu.cost.held(GameState.RESOURCE_ORE),
controller.skill_menu.cost.priced(GameState.RESOURCE_ORE)])
print("TEST_RESEARCH_DOUBLE_TAP: PASS")
quit(0)

View file

@ -98,9 +98,9 @@ func _process(delta: float) -> bool:
controller._confirm_build()
if game_state.extractors.size() != before:
return _fail("an extractor was built on a research_lab planet")
if not ("nothing to extract" in controller.build_status.text):
return _fail("refusal did not explain itself, status was '%s'" % controller.build_status.text)
print("extractor refused on a research_lab planet, explained: '%s'" % controller.build_status.text)
if not ("nothing to extract" in controller.build_menu.status.text):
return _fail("refusal did not explain itself, status was '%s'" % controller.build_menu.status.text)
print("extractor refused on a research_lab planet, explained: '%s'" % controller.build_menu.status.text)
print("TEST_RESEARCH_LAB_EXTRACTOR_GATE: PASS")
quit(0)

View file

@ -127,16 +127,14 @@ func _process(delta: float) -> bool:
% [controller.transfer_building_id, crafter_id])
# --- 6. the shared bar follows the panel --------------------
for bar in controller.resource_bars:
_expect(bar.building_id == crafter_id,
"a resource bar is reporting building %d, not the crafter %d"
% [bar.building_id, crafter_id])
_expect(controller.transfer_menu.building_id == crafter_id,
"transfer scene did not own the selected building context")
# --- 5. an empty hold explains itself -----------------------
game_state.fleet_cargo = 0
controller._on_transfer(true, GameState.CARGO_LOAD_BATCH)
_expect("carrying nothing" in controller.transfer_status.text,
"unloading an empty hold said: %s" % controller.transfer_status.text)
_expect("carrying nothing" in controller.transfer_menu.status.text,
"unloading an empty hold said: %s" % controller.transfer_menu.status.text)
# --- 2. both directions at an ordinary building -------------
# Baseline taken AFTER the last hand-set figure: the conservation

View file

@ -125,19 +125,19 @@ func _process(delta: float) -> bool:
return true
# The tree is a tall, vertically scrolling canvas. A branch keeps a
# stable lane as it rises, and dragging exposes higher depths.
var freight: Vector2 = controller.skill_tree.node_screen_pos(game_state.skill_node("freight"))
var yards: Vector2 = controller.skill_tree.node_screen_pos(game_state.skill_node("freight_yards"))
var freight: Vector2 = controller.skill_menu.tree.node_screen_pos(game_state.skill_node("freight"))
var yards: Vector2 = controller.skill_menu.tree.node_screen_pos(game_state.skill_node("freight_yards"))
if absf(freight.x - yards.x) > 1.0 or yards.y >= freight.y:
print("TEST_SKILL_TREE: FAIL (freight branch does not rise vertically)")
quit(1)
return true
var before_scroll: float = controller.skill_tree.scroll_y
var before_scroll: float = controller.skill_menu.tree.scroll_y
var drag: InputEventScreenDrag = InputEventScreenDrag.new()
# Start at the root at the bottom; pull the canvas down to reveal
# the branches rising above it.
drag.relative = Vector2(0.0, 180.0)
controller.skill_tree.handle_drag(drag)
if controller.skill_tree.scroll_y >= before_scroll:
controller.skill_menu.tree.handle_drag(drag)
if controller.skill_menu.tree.scroll_y >= before_scroll:
print("TEST_SKILL_TREE: FAIL (tree did not scroll upward)")
quit(1)
return true