#136, #137, #134: one build grid that cannot stack, one way to show a price

#136 - the gas burner drawn on top of the transport hub. The build menu's
rows were a written-down list of tops read with `tops[mini(row, size - 1)]`,
and with every structure researched the menu wanted a fourth row where the
list had three, so the last entry was placed on the third row's centre slot.
A list that silently stacks whatever will not fit in it is not a layout.
Rows are computed now, from a first top and a pitch that tightens to fit and
is floored at the row's own height - so two rows cannot occupy the same
space however far the catalog grows - and the column count widens rather
than adding a row the band cannot hold. Ten structures now sit in four
columns of three rows. The test unlocks the whole tree and checks every pair
of entries for overlap, which is the shape of the bug rather than a
coordinate that happens to be right today.

#137 - the game had three answers to "what does this cost". The build menu
drew a NeonNumber under each option, the craft menu drew glyph strips, and
the ship lab spelled prices out inside the caption. PriceTag is that answer
once: the resource's glyph, then what you HOLD over what it COSTS. It
replaces all three, plus the info popup and the skill panel, and
VesselPriceStrip - which was the same idea for one menu - goes with them.
The build menu also gains the resource bar the other two already had, in the
same place and at the same size, and the craft menu's - the only one not
running the full width of its panel - now matches. The bar drops its LOCAL
column where there is no building to report on, for the same reason #95
dropped it where it only repeated BANKED.

#134 - the skill panel's explanation and its price were both centred in the
same band, so a long description landed on the number. The text is the left
of the band now and the tag is the right. The shortfall it used to spell out
in a sentence - one per case, ore or research points - is the red on the tag
instead, and research points ride along as an entry of their own, so the tag
says WHICH thing is missing without a line of prose per possibility.

One trap worth recording: PriceTag and ResourceBar read the GameState
autoload, and an `extends SceneTree` test script that so much as names such
a class - or preloads the file - fails to compile, because GameState is not
an identifier in that context. GDScript then caches the failure, so the
scene the test instantiates gets a Control with no script where its resource
bar should be. The tests compare script PATHS, as strings.
This commit is contained in:
Loïc Wisniewski 2026-08-28 15:05:20 +02:00
commit ea25318201
12 changed files with 661 additions and 199 deletions

View file

@ -109,7 +109,12 @@ var lab_building_id: int = 0
@onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @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_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel")
@onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel") @onready var info_message: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/MessageLabel")
@onready var info_cost: NeonNumber = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber") # Typed Control, not PriceTag, and deliberately. PriceTag reads the GameState
# autoload, and an `extends SceneTree` test script that so much as names a
# class like that fails to compile - GameState is not an identifier in that
# context - which then poisons the scene's copy of the same script. The nodes
# are duck-typed everywhere for that reason; see tests/test_menu_price_unification.gd.
@onready var info_cost: Control = get_node("../UI/BuildMenu/InfoPopup/Panel/CostNumber")
@onready var info_build_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/BuildButton") @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 info_close_button: IconButton = get_node("../UI/BuildMenu/InfoPopup/Panel/CloseButton")
@ -177,7 +182,7 @@ var garrison_building_id: int = 0
@onready var skill_menu: Control = get_node("../UI/SkillMenu") @onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree") @onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel") @onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel")
@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber") @onready var skill_cost: Control = get_node("../UI/SkillMenu/CostNumber")
@onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel") @onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel")
@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton") @onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton")
@ -268,6 +273,7 @@ func _ready() -> void:
_seat_hud_columns() _seat_hud_columns()
_layout_hangar() _layout_hangar()
_build_craft_price_strips() _build_craft_price_strips()
_build_lab_price_tags()
_layout_craft_menu() _layout_craft_menu()
build_button.pressed.connect(_on_build_pressed) build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(_close_build_menu) build_close_button.pressed.connect(_close_build_menu)
@ -342,12 +348,12 @@ func _configure_build_menu_entries() -> void:
continue continue
var button: IconButton = get_node("../UI/BuildMenu/%s" % definition.menu_button_name) 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 label: Label = get_node("../UI/BuildMenu/%s" % definition.menu_label_name)
var cost_number: NeonNumber = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name) var cost_tag: Control = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name)
button.icon = definition.menu_icon button.icon = definition.menu_icon
button.pressed.connect(_show_building_info.bind(String(definition.definition_id))) button.pressed.connect(_show_building_info.bind(String(definition.definition_id)))
label.text = definition.display_name label.text = definition.display_name
cost_number.amount = definition.cost cost_tag.show_price({GameState.RESOURCE_ORE: int(definition.cost)})
build_menu_entries.append([definition.definition_id, button, label, cost_number, definition]) build_menu_entries.append([definition.definition_id, button, label, cost_tag, definition])
# True while a modal panel owns the shared context controls. A plain building # True while a modal panel owns the shared context controls. A plain building
@ -671,25 +677,83 @@ func _planet_under_fleet() -> Node2D:
# entries the grid goes to three columns instead, which takes nine into three # entries the grid goes to three columns instead, which takes nine into three
# rows - fewer than it uses today, not more. Narrower columns mean narrower # rows - fewer than it uses today, not more. Narrower columns mean narrower
# labels, so those get a taller box to wrap into. # labels, so those get a taller box to wrap into.
const MENU_ROW_TOPS: Array = [-300.0, -77.0, 146.0, 369.0] # The build menu's grid.
const MENU_COL_X: float = 140.0 #
# #136: rows used to be a written-down list of tops, read with
# `tops[mini(row, tops.size() - 1)]`. That clamp is the bug the ticket
# reported: with every structure researched the menu wanted a fourth row and
# the list had three, so the last entry was placed on the third row's centre
# slot - the gas burner drawn exactly on top of the transport hub. A list
# that silently stacks whatever will not fit in it is not a layout.
#
# So rows are COMPUTED from a first top and a pitch, and the pitch is
# tightened when the band is not tall enough - never below a row's own
# height, which is the guarantee that two rows can no longer occupy the same
# space whatever the catalog grows to. Columns widen for the same reason:
# a count that would need more rows than the band holds gets another column
# instead of another row.
const MENU_BUTTON_HALF: float = 64.0 const MENU_BUTTON_HALF: float = 64.0
const MENU_BUTTON_H: float = 100.0 const MENU_BUTTON_H: float = 100.0
const MENU_LABEL_HALF: float = 150.0 const MENU_COST_HALF: float = 74.0
const MENU_LABEL_H: float = 52.0
const MENU_COST_HALF: float = 60.0
const MENU_COST_H: float = 44.0 const MENU_COST_H: float = 44.0
# Gaps from each row's own top to where the label/cost start, sized to the
# shrunk MENU_*_H values above.
const MENU_LABEL_OFFSET: float = 106.0 const MENU_LABEL_OFFSET: float = 106.0
const MENU_COST_OFFSET: float = 162.0
const MENU_WIDE_THRESHOLD: int = 8 # Per column-count geometry. The two-column figures are the ones the menu has
const MENU_ROW_TOPS_WIDE: Array = [-330.0, -85.0, 160.0] # always used; the three-column ones came with the wide mode; four columns is
const MENU_COL_X_WIDE: float = 224.0 # what ten structures now need to stay inside three rows.
const MENU_LABEL_HALF_WIDE: float = 108.0 const MENU_COL_X: Dictionary = {2: 140.0, 3: 224.0, 4: 258.0}
const MENU_LABEL_H_WIDE: float = 68.0 const MENU_LABEL_HALF: Dictionary = {2: 150.0, 3: 108.0, 4: 84.0}
const MENU_COST_OFFSET_WIDE: float = 178.0 const MENU_LABEL_H: Dictionary = {2: 52.0, 3: 68.0, 4: 68.0}
const MENU_COST_OFFSET: Dictionary = {2: 162.0, 3: 178.0, 4: 178.0}
const MENU_ROW_PITCH: Dictionary = {2: 223.0, 3: 245.0, 4: 245.0}
# The band the grid may occupy, in the menu's own centre-anchored space: from
# under the status line (which sits below the resource bar added in #137) to
# just short of the bottom of a 1280-tall screen.
const MENU_GRID_TOP: float = -252.0
const MENU_GRID_BOTTOM: float = 612.0
# How tall one row is, end to end - the cost strip is the last thing in it.
func menu_row_height(columns: int) -> float:
return float(MENU_COST_OFFSET[columns]) + MENU_COST_H
# The fewest columns that keep the grid inside MENU_MAX_ROWS. Two is the shape
# the menu is meant to have; the wider ones are what a growing catalog forces.
const MENU_MAX_ROWS: int = 4
func menu_columns(count: int) -> int:
for columns in [2, 3, 4]:
var rows: int = int(ceil(float(count) / float(columns)))
if rows <= MENU_MAX_ROWS and _rows_fit(rows, columns):
return columns
return 4
func menu_rows(count: int) -> int:
var columns: int = menu_columns(count)
return maxi(int(ceil(float(count) / float(columns))), 1)
func _rows_fit(rows: int, columns: int) -> bool:
if rows <= 1:
return true
var height: float = menu_row_height(columns)
return (MENU_GRID_BOTTOM - MENU_GRID_TOP - height) / float(rows - 1) >= height
# Where one row starts. Top-anchored under the header, with the pitch closed
# up when the band is tight - but never below the row height, so two rows
# cannot land on each other however many structures there are (#136).
func menu_row_top(row: int, rows: int, columns: int) -> float:
var height: float = menu_row_height(columns)
var pitch: float = float(MENU_ROW_PITCH[columns])
if rows > 1:
pitch = minf(pitch,
(MENU_GRID_BOTTOM - MENU_GRID_TOP - height) / float(rows - 1))
return MENU_GRID_TOP + maxf(pitch, height) * float(row)
func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void: func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void:
@ -714,13 +778,12 @@ func _layout_build_menu() -> void:
if visible: if visible:
shown.append(entry) shown.append(entry)
var wide: bool = shown.size() > MENU_WIDE_THRESHOLD var columns: int = menu_columns(shown.size())
var columns: int = 3 if wide else 2 var rows: int = menu_rows(shown.size())
var tops: Array = MENU_ROW_TOPS_WIDE if wide else MENU_ROW_TOPS var col_x: float = float(MENU_COL_X[columns])
var col_x: float = MENU_COL_X_WIDE if wide else MENU_COL_X var label_half: float = float(MENU_LABEL_HALF[columns])
var label_half: float = MENU_LABEL_HALF_WIDE if wide else MENU_LABEL_HALF var label_h: float = float(MENU_LABEL_H[columns])
var label_h: float = MENU_LABEL_H_WIDE if wide else MENU_LABEL_H var cost_offset: float = float(MENU_COST_OFFSET[columns])
var cost_offset: float = MENU_COST_OFFSET_WIDE if wide else MENU_COST_OFFSET
for i in range(shown.size()): for i in range(shown.size()):
var row: int = i / columns var row: int = i / columns
@ -730,7 +793,7 @@ func _layout_build_menu() -> void:
var col: int = i % columns var col: int = i % columns
var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * col_x * 2.0 \ var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * col_x * 2.0 \
/ maxf(float(columns) - 1.0, 1.0) / maxf(float(columns) - 1.0, 1.0)
var top: float = tops[mini(row, tops.size() - 1)] var top: float = menu_row_top(row, rows, columns)
_place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H)
_place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h) _place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h)
_place_entry(shown[i][3], cx, top + cost_offset, MENU_COST_HALF, MENU_COST_H) _place_entry(shown[i][3], cx, top + cost_offset, MENU_COST_HALF, MENU_COST_H)
@ -871,12 +934,14 @@ func _show_building_info(type: String) -> void:
# charging bank-plus-hold below would hide the build button for something # charging bank-plus-hold below would hide the build button for something
# the player can in fact afford. # the player can in fact afford.
if can_build and not GameState.can_afford(cost): if can_build and not GameState.can_afford(cost):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.available_ore()]) # #137: no sentence about the shortfall. The price tag below carries
# what is held over what is wanted, in red - saying it twice, in two
# different shapes, is what the ticket is about.
can_build = false can_build = false
info_title.text = definition.display_name info_title.text = definition.display_name
info_message.text = "\n\n".join(lines) info_message.text = "\n\n".join(lines)
info_cost.amount = cost info_cost.show_price({GameState.RESOURCE_ORE: cost})
info_build_button.visible = can_build info_build_button.visible = can_build
pending_build_type = type if can_build else "" pending_build_type = type if can_build else ""
info_popup.visible = true info_popup.visible = true
@ -967,7 +1032,7 @@ func _refresh_skill_menu(id: String = "") -> void:
# Nothing chosen yet: point the player at the tree rather than # Nothing chosen yet: point the player at the tree rather than
# leaving the panel blank, which reads as broken. # leaving the panel blank, which reads as broken.
skill_detail.text = "Select a node to see what it unlocks." skill_detail.text = "Select a node to see what it unlocks."
skill_cost.amount = 0 skill_cost.show_price({})
skill_status.text = "Fleet capacity %d · Research points %d" \ skill_status.text = "Fleet capacity %d · Research points %d" \
% [GameState.max_fleet_size(), GameState.research_points] % [GameState.max_fleet_size(), GameState.research_points]
return return
@ -976,25 +1041,26 @@ func _refresh_skill_menu(id: String = "") -> void:
if node.is_empty(): if node.is_empty():
return return
var points_needed: int = GameState.skill_research_point_cost(id) var points_needed: int = GameState.skill_research_point_cost(id)
# #134: the explanation and the price no longer share a column. The text
# is the left half of the band and the tag is the right half, so a long
# description can grow without ending up on top of the number - which is
# what it used to do, the two being centred over each other.
#
# Both shortfalls it used to spell out - ore and research points - are
# now entries on the tag, held-over-needed, in red when short. One place,
# and it says WHICH one is missing without a sentence per case.
skill_detail.text = "%s\n%s" % [node.name, node.desc] skill_detail.text = "%s\n%s" % [node.name, node.desc]
if points_needed > 0: skill_cost.show_price({GameState.RESOURCE_ORE: int(node.cost)}, points_needed)
skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed]
skill_cost.amount = int(node.cost)
if GameState.is_unlocked(id): if GameState.is_unlocked(id):
skill_status.text = "Researched" skill_status.text = "Researched"
elif not GameState.prerequisite_met(id): elif not GameState.prerequisite_met(id):
skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name
elif not GameState.can_afford(int(node.cost)): elif not skill_cost.is_affordable():
# available_ore(), not the bank alone: research is paid for the same # Deliberately blank. The red on the tag is the whole message; the
# pooled way as everything else, so quoting the bank here would tell # line here used to repeat it in words and, being centred, repeat it
# the player they cannot afford something they can. # on top of the number as well.
skill_status.text = "Not enough rocks - you have %d" % GameState.available_ore() skill_status.text = ""
elif GameState.research_points < points_needed:
# #63: a distinct reason from the ore one above - conflating them
# would leave the player trying to fix the wrong shortage.
skill_status.text = "Not enough research points - you have %d, need %d" \
% [GameState.research_points, points_needed]
else: else:
# The button that used to appear here was the only thing saying "this # The button that used to appear here was the only thing saying "this
# is buyable". With it gone the line has to say so itself, or an # is buyable". With it gone the line has to say so itself, or an
@ -1050,7 +1116,7 @@ func _format_missing_costs(costs: Dictionary) -> String:
# overlapping each other, which left "Energy Propulser" clipped and the price # overlapping each other, which left "Energy Propulser" clipped and the price
# crushed onto a second line under it. Laid out here instead, in two rows of # crushed onto a second line under it. Laid out here instead, in two rows of
# three, so a cell is wide enough for the name to breathe - and the price is # three, so a cell is wide enough for the name to breathe - and the price is
# glyphs rather than words (see VesselPriceStrip). # glyphs rather than words (see PriceTag, which every menu now shares).
const CRAFT_COLUMNS: int = 3 const CRAFT_COLUMNS: int = 3
const CRAFT_ROW_TOPS: Array = [-96.0, 104.0] const CRAFT_ROW_TOPS: Array = [-96.0, 104.0]
const CRAFT_COL_X: float = 190.0 const CRAFT_COL_X: float = 190.0
@ -1064,10 +1130,14 @@ const CRAFT_PRICE_H: float = 32.0
const CRAFT_PRICE_OFFSET: float = 182.0 const CRAFT_PRICE_OFFSET: float = 182.0
var craft_price_strips: Array = [] var craft_price_strips: Array = []
# #137: the ship lab's own price tags, built here rather than authored in the
# scene for the same reason the craft menu's are - four identical widgets
# differing only in where they sit.
var lab_price_tags: Array = []
func _build_craft_price_strips() -> void: func _build_craft_price_strips() -> void:
var strip_script: Script = load("res://ui/VesselPriceStrip.gd") var strip_script: Script = load("res://ui/PriceTag.gd")
for i in range(craft_vessel_buttons.size()): for i in range(craft_vessel_buttons.size()):
var strip: Control = strip_script.new() var strip: Control = strip_script.new()
strip.name = "CraftPrice%d" % i strip.name = "CraftPrice%d" % i
@ -1082,6 +1152,34 @@ func _build_craft_price_strips() -> void:
craft_price_strips.append(strip) craft_price_strips.append(strip)
# One tag per upgrade slot, seated in the gap the caption's second line used
# to occupy. The captions above them shrink to one line to make room, so the
# option block is the same height it was and the row below it does not move.
const LAB_TAG_H: float = 38.0
const LAB_LABEL_H: float = 40.0
func _build_lab_price_tags() -> void:
var tag_script: Script = load("res://ui/PriceTag.gd")
for i in range(lab_upgrade_labels.size()):
var label: Control = lab_upgrade_labels[i]
var tag: Control = tag_script.new()
tag.name = "LabPrice%d" % i
tag.anchor_left = label.anchor_left
tag.anchor_right = label.anchor_right
tag.anchor_top = label.anchor_top
tag.anchor_bottom = label.anchor_bottom
tag.offset_left = label.offset_left
tag.offset_right = label.offset_right
# Under the caption, which is cut to a single line here so the two
# together occupy what the two-line caption used to.
label.offset_bottom = label.offset_top + LAB_LABEL_H
tag.offset_top = label.offset_bottom + 2.0
tag.offset_bottom = tag.offset_top + LAB_TAG_H
lab_menu.add_child(tag)
lab_price_tags.append(tag)
func _layout_craft_menu() -> void: func _layout_craft_menu() -> void:
for i in range(craft_vessel_buttons.size()): for i in range(craft_vessel_buttons.size()):
var row: int = i / CRAFT_COLUMNS var row: int = i / CRAFT_COLUMNS
@ -1220,19 +1318,28 @@ func _refresh_lab() -> void:
var visible: bool = i < options.size() var visible: bool = i < options.size()
lab_upgrade_buttons[i].visible = visible lab_upgrade_buttons[i].visible = visible
lab_upgrade_labels[i].visible = visible lab_upgrade_labels[i].visible = visible
if i < lab_price_tags.size():
lab_price_tags[i].visible = visible
if not visible: if not visible:
continue continue
var option: Dictionary = options[i] var option: Dictionary = options[i]
var price_text: String = _format_costs(option.costs if bool(option.unlock) \ # #137: the price is a tag under the option, the way it is under a
else GameState.research_costs(option.id)) # build option and under a vessel in the craft menu. It used to be a
var point_cost: int = GameState.research_point_cost(String(option.id)) # second line of the caption - "25 ore · 10 gas · 2 RP" - which is
if point_cost > 0: # the same information in a shape no other menu used.
price_text += " · %d RP" % point_cost var maxed: bool = not bool(option.unlock) and GameState.upgrade_maxed(option.id)
if not bool(option.unlock) and GameState.upgrade_maxed(option.id):
price_text = "MAX"
var level_note: String = "" if bool(option.unlock) else " %d/%d" % [ var level_note: String = "" if bool(option.unlock) else " %d/%d" % [
GameState.upgrade_level(option.id), int(GameState.upgrade_def(option.id).max_level)] GameState.upgrade_level(option.id), int(GameState.upgrade_def(option.id).max_level)]
lab_upgrade_labels[i].text = "%s%s\n%s" % [option.name, level_note, price_text] lab_upgrade_labels[i].text = "%s%s" % [option.name, level_note]
if i < lab_price_tags.size():
var tag: Control = lab_price_tags[i]
tag.visible = not maxed
if maxed:
tag.show_price({})
else:
tag.show_price(option.costs if bool(option.unlock) \
else GameState.research_costs(option.id),
GameState.research_point_cost(String(option.id)))
lab_upgrade_buttons[i].icon = _upgrade_icon(option) lab_upgrade_buttons[i].icon = _upgrade_icon(option)
lab_upgrade_buttons[i].glow_color = _vessel_colour(lab_vessel) lab_upgrade_buttons[i].glow_color = _vessel_colour(lab_vessel)
lab_upgrade_buttons[i].highlighted = i == lab_choice lab_upgrade_buttons[i].highlighted = i == lab_choice

View file

@ -20,7 +20,9 @@ var controller: Node
var popup: Control var popup: Control
var title: Label var title: Label
var message: Label var message: Label
var cost_number: NeonNumber # Control, not PriceTag: naming a class whose body reads the GameState
# autoload does not compile inside an `extends SceneTree` script.
var cost_number: Control
var build_button: IconButton var build_button: IconButton
var stage: int = 0 var stage: int = 0
var elapsed: float = 0.0 var elapsed: float = 0.0
@ -89,11 +91,12 @@ func _process(delta: float) -> bool:
var informed: bool = popup.visible \ var informed: bool = popup.visible \
and title.text == option.title \ and title.text == option.title \
and message.text.length() > 40 \ and message.text.length() > 40 \
and cost_number.amount == expected_cost \ and cost_number.priced(GameState.RESOURCE_ORE) == expected_cost \
and build_button.visible and build_button.visible
var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before
print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [ print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [
type, popup.visible, title.text, cost_number.amount, type, popup.visible, title.text,
cost_number.priced(GameState.RESOURCE_ORE),
build_button.visible, untouched build_button.visible, untouched
]) ])
if not (informed and untouched): if not (informed and untouched):

View file

@ -17,6 +17,9 @@ extends SceneTree
# 4. an entry alone on its row is centred, not hanging in the left column # 4. an entry alone on its row is centred, not hanging in the left column
# 5. researched-but-not-usable is still shown greyed (an extractor with no # 5. researched-but-not-usable is still shown greyed (an extractor with no
# planet under the fleet is "not now", not "not yet") # planet under the fleet is "not now", not "not yet")
# 6. #136 - with every structure researched, no two entries overlap. The
# grid used to be a fixed list of row positions read with a clamp, so
# the first row that would not fit was drawn on top of another one
var game_state: Node var game_state: Node
var scene: Node var scene: Node
@ -45,6 +48,12 @@ func _shown() -> Array:
return out return out
func _rect(node: Control) -> Rect2:
return Rect2(Vector2(node.offset_left, node.offset_top),
Vector2(node.offset_right - node.offset_left,
node.offset_bottom - node.offset_top))
func _slots() -> Array: func _slots() -> Array:
# (row, x) of every visible button, in menu order. # (row, x) of every visible button, in menu order.
var out: Array = [] var out: Array = []
@ -100,21 +109,51 @@ func _process(delta: float) -> bool:
# --- 3. consecutive slots, no holes ------------------------- # --- 3. consecutive slots, no holes -------------------------
var slots: Array = _slots() var slots: Array = _slots()
var rows: Array = controller.MENU_ROW_TOPS var columns: int = controller.menu_columns(slots.size())
var rows: int = controller.menu_rows(slots.size())
if columns != 2:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (4 entries laid out in %d columns)" % columns)
quit(1)
return true
for i in range(slots.size()): for i in range(slots.size()):
var want_row: float = rows[i / 2] var want_row: float = controller.menu_row_top(i / columns, rows, columns)
if absf(slots[i][0] - want_row) > 0.01: if absf(slots[i][0] - want_row) > 0.01:
print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / 2, want_row]) print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / columns, want_row])
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)") print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)")
quit(1) quit(1)
return true return true
var want_x: float = -controller.MENU_COL_X if i % 2 == 0 else controller.MENU_COL_X var col_x: float = float(controller.MENU_COL_X[columns])
var want_x: float = -col_x if i % 2 == 0 else col_x
if absf(slots[i][1] - want_x) > 0.01: if absf(slots[i][1] - want_x) > 0.01:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x]) print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x])
quit(1) quit(1)
return true return true
print("4 entries packed into consecutive slots, no gaps") print("4 entries packed into consecutive slots, no gaps")
# --- 6. #136: nothing overlaps, with EVERY structure on offer -
# The bug the ticket reported: the row list ran out and the last
# entry was placed on top of one already there.
for node in GameState.SKILL_TREE:
game_state.unlocked.append(String(node.id))
controller._refresh_build_status()
var all_shown: Array = _shown()
if all_shown.size() < 9:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (only %d structures unlocked - not the crowded case)" % all_shown.size())
quit(1)
return true
for i in range(all_shown.size()):
for j in range(i + 1, all_shown.size()):
for part in [1, 2, 3]:
var a: Control = all_shown[i][part]
var b: Control = all_shown[j][part]
if _rect(a).intersects(_rect(b)):
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (%s and %s overlap at %s / %s)" \
% [all_shown[i][0], all_shown[j][0], _rect(a), _rect(b)])
quit(1)
return true
print("%d structures on offer, in %d columns, nothing overlapping" \
% [all_shown.size(), controller.menu_columns(all_shown.size())])
# --- 5. researched but not usable is shown, greyed ---------- # --- 5. researched but not usable is shown, greyed ----------
var extractor_shown: bool = false var extractor_shown: bool = false
for e in shown2: for e in shown2:

View file

@ -0,0 +1,192 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_menu_price_unification.gd
# Forgejo #137: "the resources at the top are not always shown the same way,
# the price is not always put at the same place... the build menu, the
# research menu, the ship crafter menu would benefit from a more unified
# UX." And #134, which is the same complaint aimed at the skill panel: the
# explanation could land on top of the price, and a shortfall was a sentence
# instead of a number.
#
# The point of the ticket is CONSISTENCY, which is a property of several
# things at once - so this test compares the menus against each other rather
# than against written-down coordinates. A menu that moves is fine; a menu
# that moves away from the others is the bug.
#
# Covers:
# A. every buying menu has a resource bar, in the same place, the same size
# B. every price is the same widget - one class, not three presentations
# C. a price says what you HAVE as well as what it COSTS, with the held
# figure smaller than the price (#134) and red when it is short
# D. #134 - the skill panel's explanation is left of its price, not on top
# of it, and the two do not overlap at any description length
# E. the shortfall is no longer also spelled out in a sentence
const PRICED_MENUS: Array = ["BuildMenu", "CraftMenu", "LabMenu"]
# Compared by SCRIPT PATH, as a string, and that is not fussiness.
# ResourceBar and PriceTag both read the GameState autoload, and an
# `extends SceneTree` script that so much as pulls one of them in - naming
# the class, or preloading the file - fails to compile, because GameState is
# not an identifier in this context. Worse, GDScript caches that failure, so
# the scene instantiated a few lines below would get a Control with no script
# where its resource bar should be. A string names the file without asking
# for it, and "is this the same script" is the question anyway.
const RESOURCE_BAR_PATH: String = "res://ui/ResourceBar.gd"
const PRICE_TAG_PATH: String = "res://ui/PriceTag.gd"
var game_state: Node
var scene: Node
var controller: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.reset_new_game()
game_state.intro_active = false
game_state.reset_quests()
game_state.rocks = 40
func _fail(message: String) -> bool:
print("TEST_MENU_PRICE_UNIFICATION: FAIL (%s)" % message)
quit(1)
return true
func _script_path(node: Node) -> String:
var script: Script = node.get_script()
return "" if script == null else script.resource_path
# The RESOLVED rectangle, not the offsets. Two Controls with different
# anchors have offsets measured from different edges, so comparing those
# compares nothing - and subtracting them produces negative sizes, which
# Rect2.intersects refuses outright.
func _rect(node: Control) -> Rect2:
return node.get_rect()
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
controller = scene.get_node("BuildController")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
# --- A. the same bar, in the same place ------------------
var bars: Dictionary = {}
for menu in PRICED_MENUS:
var bar: Control = scene.get_node_or_null("UI/%s/Bar" % menu)
if bar == null:
return _fail("%s has no resource bar - the other menus do" % menu)
if _script_path(bar) != RESOURCE_BAR_PATH:
return _fail("%s's bar is not a ResourceBar" % menu)
bars[menu] = bar
var reference: Rect2 = _rect(bars[PRICED_MENUS[0]])
for menu in PRICED_MENUS:
var here: Rect2 = _rect(bars[menu])
if here != reference:
return _fail("%s's resource bar sits at %s, the others at %s" \
% [menu, here, reference])
if bars[menu].anchor_right != bars[PRICED_MENUS[0]].anchor_right:
return _fail("%s's resource bar is anchored differently" % menu)
print("%d menus, one resource bar geometry: %s" % [PRICED_MENUS.size(), reference])
# --- 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)
if tags.size() < 15:
return _fail("only %d price widgets found - the menus cannot all be covered" % tags.size())
for tag in tags:
if _script_path(tag) != PRICE_TAG_PATH:
return _fail("a price is drawn by %s, not the shared price tag" \
% _script_path(tag))
print("%d prices across the build, craft, lab and skill menus, all one widget" % tags.size())
# --- 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
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")
if probe.held(GameState.RESOURCE_ORE) != 3:
return _fail("the tag reads %d held, the player has 3" \
% probe.held(GameState.RESOURCE_ORE))
if not probe.is_short(GameState.RESOURCE_ORE):
return _fail("3 out of 25 is not marked short")
if probe.is_affordable():
return _fail("a tag the player cannot pay reads as affordable")
# Read off the labels the player actually sees, rather than off
# the constants behind them.
var entry: Dictionary = probe._entry_for(GameState.RESOURCE_ORE)
var have_size: int = entry.have_label.get_theme_font_size("font_size")
var cost_size: int = entry.cost_label.get_theme_font_size("font_size")
if have_size >= cost_size:
return _fail("what you hold (size %d) is not drawn smaller than the price (size %d)" \
% [have_size, cost_size])
var short_colour: Color = entry.have_label.get_theme_color("font_color")
if short_colour.r <= short_colour.g or short_colour.r <= short_colour.b:
return _fail("the short figure is drawn in %s, which is not a warning colour" % short_colour)
print("price tag: %d/%d ore, held figure at size %d in %s, price at size %d" \
% [probe.held(GameState.RESOURCE_ORE), probe.priced(GameState.RESOURCE_ORE),
have_size, short_colour, cost_size])
# ...and the affordable case is not red, or the colour says nothing.
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 900)
probe.show_price({GameState.RESOURCE_ORE: 25})
if probe.is_short(GameState.RESOURCE_ORE) or not probe.is_affordable():
return _fail("900 ore does not cover a price of 25")
if probe._entry_for(GameState.RESOURCE_ORE).have_label \
.get_theme_color("font_color") == short_colour:
return _fail("an affordable price is still drawn in the shortfall colour")
print("and an affordable price is drawn differently")
# --- D. #134: the explanation is beside the price --------
var longest: String = ""
for node in GameState.SKILL_TREE:
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)
if detail.intersects(price):
return _fail("the explanation (%s) overlaps the price (%s)" % [detail, price])
if detail.position.x >= price.position.x:
return _fail("the explanation is not left of the price")
print("skill panel: explanation at x=%.0f, price at x=%.0f, no overlap (longest of %d descriptions)" \
% [detail.position.x, price.position.x, GameState.SKILL_TREE.size()])
# --- E. and the shortfall is not also a sentence ---------
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
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():
return _fail("a node reads as affordable with no ore at all")
if controller.skill_status.text.to_lower().contains("not enough"):
return _fail("the shortfall is still written out as well: '%s'" \
% controller.skill_status.text)
print("the shortfall is shown once, on the price, and not repeated in words")
print("TEST_MENU_PRICE_UNIFICATION: PASS")
quit(0)
return true
return false

View file

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

View file

@ -125,9 +125,15 @@ func _process(delta: float) -> bool:
if game_state.is_unlocked(second): if game_state.is_unlocked(second):
return _fail("a node was bought with no ore at all") return _fail("a node was bought with no ore at all")
controller._refresh_skill_menu(second) controller._refresh_skill_menu(second)
if controller.skill_status.text == "": # #134: the refusal is shown on the price rather than written out in a
return _fail("an unaffordable node was refused silently") # sentence below it - what you hold over what it costs, in red.
print("unaffordable node refused, and says so: '%s'" % controller.skill_status.text) if controller.skill_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):
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)])
print("TEST_RESEARCH_DOUBLE_TAP: PASS") print("TEST_RESEARCH_DOUBLE_TAP: PASS")
quit(0) quit(0)

198
ui/PriceTag.gd Normal file
View file

@ -0,0 +1,198 @@
class_name PriceTag
extends Control
# Forgejo #137 (and #134): one way of saying what something costs.
#
# The game had three. The build menu drew a NeonNumber under each option; the
# craft menu drew glyph-and-number strips (#111); the ship lab spelled prices
# out inside the option's caption - "25 ore · 10 gas · 2 RP" - and the skill
# tree put the number in the middle of the panel with a sentence underneath
# saying whether you could afford it. Four menus, four answers to the same
# question, and a player learning one of them learned nothing about the next.
#
# This is that answer, once: for each thing a purchase costs, its glyph, then
# what you HAVE over what it COSTS.
#
# [ore] 12/25 [gas] 40/10
#
# #134 asks for the two numbers to be told apart by more than position, so
# the amount you have is drawn smaller than the price - a price is a fact
# about the thing, what you hold is a fact about you - and it turns red when
# it is not enough. That replaces the "Not enough rocks - you have 12" line
# the skill panel used to carry: the shortfall is now shown where the price
# is rather than described in a sentence somewhere else on the panel.
#
# Research points ride along as an entry with a written mark rather than a
# glyph, because they are not a resource - they have no inventory, no colour
# in the catalog, and nothing carries them.
enum Align { LEFT, CENTER, RIGHT }
const GLYPH_SIZE: float = 26.0
const GAP: float = 5.0
const PAIR_GAP: float = 18.0
const COST_FONT_SIZE: int = 24
# Deliberately smaller than the price. See the note above.
const HAVE_FONT_SIZE: int = 17
const SHORT_COLOR: Color = Color(1.0, 0.36, 0.32)
const HAVE_COLOR: Color = Color(0.60, 0.73, 0.82)
const POINT_COLOR: Color = Color(0.72, 0.62, 1.0)
const POINT_MARK: String = "RP"
@export var alignment: int = Align.CENTER
# Off for a price with nothing to compare against - a shop with no wallet in
# the same breath. Every menu in the game wants it on; it exists so a caller
# that only has a number can still use this widget rather than inventing a
# fifth way to draw one.
@export var show_available: bool = true
var _entries: Array = []
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
resized.connect(_layout)
# costs: {resource_type: amount}. points: research points, 0 for none.
func show_price(costs: Dictionary, points: int = 0) -> void:
_clear()
for resource_type in GameState.resource_types():
var amount: int = int(costs.get(resource_type, 0))
if amount <= 0:
continue
_add_entry(String(resource_type), "", amount,
GameState.available_resource(String(resource_type)),
GameState.resource_color(resource_type))
if points > 0:
_add_entry("", POINT_MARK, points, GameState.research_points, POINT_COLOR)
_layout()
# The old name, kept because the craft menu speaks in costs alone and reads
# better for it.
func set_costs(costs: Dictionary) -> void:
show_price(costs)
func is_free() -> bool:
return _entries.is_empty()
# Whether anything on this tag is out of reach. The one question every caller
# used to answer for itself, in its own words.
func is_affordable() -> bool:
for entry in _entries:
if int(entry.have) < int(entry.cost):
return false
return true
# What this tag says about one resource. Accessors rather than a public
# dictionary so a caller - or a test - reads the same numbers the player
# does, off the thing that drew them.
func priced(resource_type: String) -> int:
return int(_entry_for(resource_type).get("cost", 0))
func held(resource_type: String) -> int:
return int(_entry_for(resource_type).get("have", 0))
func is_short(resource_type: String) -> bool:
return bool(_entry_for(resource_type).get("short", false))
func _entry_for(resource_type: String) -> Dictionary:
for entry in _entries:
if String(entry.resource) == resource_type:
return entry
return {}
func _clear() -> void:
for entry in _entries:
for node in [entry.mark, entry.have_label, entry.cost_label]:
if node != null:
node.queue_free()
_entries.clear()
func _add_entry(resource_type: String, mark_text: String, cost: int, have: int,
colour: Color) -> void:
var mark: Control
if resource_type != "":
var glyph := ResourceGlyph.new()
glyph.resource_type = resource_type
glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE
mark = glyph
else:
mark = _make_label(mark_text, HAVE_FONT_SIZE, colour)
add_child(mark)
var short: bool = have < cost
var have_label: Label = null
if show_available:
have_label = _make_label("%d/" % have, HAVE_FONT_SIZE,
SHORT_COLOR if short else HAVE_COLOR)
add_child(have_label)
var cost_label: Label = _make_label(str(cost), COST_FONT_SIZE, colour)
add_child(cost_label)
_entries.append({
"resource": resource_type,
"mark": mark, "have_label": have_label, "cost_label": cost_label,
"cost": cost, "have": have, "short": short,
"have_width": 0.0 if have_label == null else _text_width(have_label, HAVE_FONT_SIZE),
"cost_width": _text_width(cost_label, COST_FONT_SIZE),
})
func _make_label(text: String, font_size: int, colour: Color) -> Label:
var label := Label.new()
label.text = text
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", colour)
label.add_theme_color_override("font_outline_color", Color(0.01, 0.01, 0.035, 0.95))
label.add_theme_constant_override("outline_size", 2)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
return label
func _text_width(label: Label, font_size: int) -> float:
var font: Font = label.get_theme_font("font")
if font == null:
return float(label.text.length()) * float(font_size) * 0.6
return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x
func _entry_width(entry: Dictionary) -> float:
return GLYPH_SIZE + GAP + float(entry.have_width) + float(entry.cost_width)
func _layout() -> void:
var total: float = 0.0
for entry in _entries:
total += _entry_width(entry) + PAIR_GAP
total = maxf(total - PAIR_GAP, 0.0)
var x: float = 0.0
match alignment:
Align.CENTER:
x = (size.x - total) * 0.5
Align.RIGHT:
x = size.x - total
var mid: float = (size.y - GLYPH_SIZE) * 0.5
for entry in _entries:
entry.mark.position = Vector2(x, mid)
entry.mark.size = Vector2(GLYPH_SIZE, GLYPH_SIZE)
x += GLYPH_SIZE + GAP
if entry.have_label != null:
entry.have_label.position = Vector2(x, 0.0)
entry.have_label.size = Vector2(float(entry.have_width), size.y)
x += float(entry.have_width)
entry.cost_label.position = Vector2(x, 0.0)
entry.cost_label.size = Vector2(float(entry.cost_width), size.y)
x += float(entry.cost_width) + PAIR_GAP

1
ui/PriceTag.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://4qp8g8ogbigj

View file

@ -26,7 +26,7 @@ var _header_labels: Array[Label] = []
var _rows: Dictionary = {} var _rows: Dictionary = {}
# The value columns, in the order they are drawn. LOCAL is dropped from this # The value columns, in the order they are drawn. LOCAL is dropped from this
# list at a building that IS the bank - see _local_is_banked(). # list where it would only repeat another column - see _local_is_redundant().
const VALUE_KEYS: Array = ["bank", "fleet", "local"] const VALUE_KEYS: Array = ["bank", "fleet", "local"]
var _local_shown: bool = true var _local_shown: bool = true
@ -79,15 +79,22 @@ func _make_value_label(colour: Color) -> Label:
return label return label
# Whether a LOCAL column would say anything a reader could act on. Two ways
# it would not:
#
# #95: at a building that IS the bank - the mothership carries both the "bank" # #95: at a building that IS the bank - the mothership carries both the "bank"
# and the "research" capability, so the research menu is always opened from one # and the "research" capability, so the research menu is always opened from one
# - building_inventory() redirects to bank_inventory itself, and LOCAL is not # - building_inventory() redirects to bank_inventory itself, and LOCAL is not
# merely equal to BANKED but the very same store read twice. Printing the # merely equal to BANKED but the very same store read twice. Printing the
# number again teaches nothing and costs a third of the table, so the column # number again teaches nothing and costs a third of the table.
# goes away there and the remaining two share the width. #
func _local_is_banked() -> bool: # #137: and at no building at all, which is how the build menu opens it - it
# is reached from the fleet's own button rather than from a structure. There
# is no "here" to report on, and a column of em-dashes is worth less than the
# width it takes from the two columns that do say something.
func _local_is_redundant() -> bool:
if building_id == 0: if building_id == 0:
return false return true
return GameState.building_inventory(building_id) == GameState.bank_inventory return GameState.building_inventory(building_id) == GameState.bank_inventory
@ -142,7 +149,7 @@ func _layout() -> void:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
# building_id is assigned by whichever panel opens, so the column count can # building_id is assigned by whichever panel opens, so the column count can
# change between frames; re-lay out only when it actually flips. # change between frames; re-lay out only when it actually flips.
var local_shown: bool = not _local_is_banked() var local_shown: bool = not _local_is_redundant()
if local_shown != _local_shown: if local_shown != _local_shown:
_local_shown = local_shown _local_shown = local_shown
_layout() _layout()

View file

@ -1,80 +0,0 @@
extends Control
# #111: what a vessel costs, in glyphs.
#
# The craft menu used to spell prices out - "15 ore · 5 gas" - which is three
# words of chrome per button on a screen with six of them, and it left the
# vessel's own name fighting for the same two lines. Every resource already
# has a glyph the resource bar and the transfer list draw; this reuses it, so
# a price reads as a number beside a mark rather than as a sentence.
#
# Built in code from the costs it is given, so a vessel whose price changes -
# or a resource added to the catalog - needs nothing here.
const GLYPH_SIZE: float = 26.0
const GAP: float = 4.0
const PAIR_GAP: float = 14.0
const FONT_SIZE: int = 24
var _pairs: Array = []
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_costs(costs: Dictionary) -> void:
for pair in _pairs:
pair.glyph.queue_free()
pair.label.queue_free()
_pairs.clear()
for resource_type in GameState.resource_types():
var amount: int = int(costs.get(resource_type, 0))
if amount <= 0:
continue
var glyph := ResourceGlyph.new()
glyph.resource_type = String(resource_type)
glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(glyph)
var label := Label.new()
label.text = str(amount)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_font_size_override("font_size", FONT_SIZE)
label.add_theme_color_override("font_color", GameState.resource_color(resource_type))
label.add_theme_color_override("font_outline_color", Color(0.01, 0.01, 0.035, 0.95))
label.add_theme_constant_override("outline_size", 2)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
add_child(label)
_pairs.append({"glyph": glyph, "label": label, "width": _text_width(label)})
_layout()
func _text_width(label: Label) -> float:
var font: Font = label.get_theme_font("font")
if font == null:
return 20.0
return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0,
FONT_SIZE).x
func _layout() -> void:
# Centred as a block: a one-resource price and a two-resource price
# should both sit under the middle of the button they belong to.
var total: float = 0.0
for pair in _pairs:
total += GLYPH_SIZE + GAP + float(pair.width) + PAIR_GAP
total = maxf(total - PAIR_GAP, 0.0)
var x: float = (size.x - total) * 0.5
var mid: float = (size.y - GLYPH_SIZE) * 0.5
for pair in _pairs:
pair.glyph.position = Vector2(x, mid)
pair.glyph.size = Vector2(GLYPH_SIZE, GLYPH_SIZE)
x += GLYPH_SIZE + GAP
pair.label.position = Vector2(x, 0.0)
pair.label.size = Vector2(float(pair.width), size.y)
x += float(pair.width) + PAIR_GAP
func is_free() -> bool:
return _pairs.is_empty()

View file

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

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=33 format=3] [gd_scene load_steps=34 format=3]
[ext_resource type="Script" path="res://world/Starfield.gd" id="1"] [ext_resource type="Script" path="res://world/Starfield.gd" id="1"]
[ext_resource type="Script" path="res://vessels/Fleet.gd" id="2"] [ext_resource type="Script" path="res://vessels/Fleet.gd" id="2"]
@ -41,6 +41,7 @@
[ext_resource type="Script" path="res://ui/SpeedFader.gd" id="31"] [ext_resource type="Script" path="res://ui/SpeedFader.gd" id="31"]
[ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"] [ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"]
[ext_resource type="Script" path="res://ui/QuestTracker.gd" id="quest_hud"] [ext_resource type="Script" path="res://ui/QuestTracker.gd" id="quest_hud"]
[ext_resource type="Script" path="res://ui/PriceTag.gd" id="price_tag"]
[sub_resource type="ShaderMaterial" id="1"] [sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5") shader = ExtResource("5")
@ -477,19 +478,28 @@ color = Color(0.03, 0.03, 0.08, 0.94)
mouse_filter = 2 mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
offset_left = 24 offset_left = 24
offset_top = 70 offset_top = 20
offset_right = -24 offset_right = -24
offset_bottom = 196 offset_bottom = 140
text = "Build" text = "Build"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Bar" type="Control" parent="UI/BuildMenu"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24.0
offset_top = 148.0
offset_right = -24.0
offset_bottom = 284.0
script = ExtResource("24")
[node name="StatusLabel" type="Label" parent="UI/BuildMenu"] [node name="StatusLabel" type="Label" parent="UI/BuildMenu"]
mouse_filter = 2 mouse_filter = 2
anchor_right = 1.0 anchor_right = 1.0
offset_left = 24 offset_left = 24
offset_top = 206 offset_top = 292
offset_right = -24 offset_right = -24
offset_bottom = 330 offset_bottom = 372
theme_override_font_sizes/font_size = 40 theme_override_font_sizes/font_size = 40
autowrap_mode = 2 autowrap_mode = 2
text = "" text = ""
@ -533,9 +543,7 @@ offset_left = -200
offset_right = -80 offset_right = -80
offset_top = -96 offset_top = -96
offset_bottom = -44 offset_bottom = -44
script = ExtResource("19") script = ExtResource("price_tag")
amount = 20
digit_height = 40.0
[node name="CrafterButton" type="Control" parent="UI/BuildMenu"] [node name="CrafterButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -575,9 +583,7 @@ offset_left = 80
offset_right = 200 offset_right = 200
offset_top = -96 offset_top = -96
offset_bottom = -44 offset_bottom = -44
script = ExtResource("19") script = ExtResource("price_tag")
amount = 12
digit_height = 40.0
[node name="CommandButton" type="Control" parent="UI/BuildMenu"] [node name="CommandButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -617,9 +623,7 @@ offset_left = -200
offset_right = -80 offset_right = -80
offset_top = 194 offset_top = 194
offset_bottom = 246 offset_bottom = 246
script = ExtResource("19") script = ExtResource("price_tag")
amount = 15
digit_height = 40.0
[node name="ExtractorButton" type="Control" parent="UI/BuildMenu"] [node name="ExtractorButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -659,9 +663,7 @@ offset_left = 80
offset_right = 200 offset_right = 200
offset_top = 194 offset_top = 194
offset_bottom = 246 offset_bottom = 246
script = ExtResource("19") script = ExtResource("price_tag")
amount = 25
digit_height = 40.0
[node name="TransportHubButton" type="Control" parent="UI/BuildMenu"] [node name="TransportHubButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -701,9 +703,7 @@ offset_left = -60
offset_right = 60 offset_right = 60
offset_top = 484 offset_top = 484
offset_bottom = 536 offset_bottom = 536
script = ExtResource("19") script = ExtResource("price_tag")
amount = 30
digit_height = 40.0
[node name="ShipLabButton" type="Control" parent="UI/BuildMenu"] [node name="ShipLabButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -743,9 +743,7 @@ offset_left = 80
offset_right = 200 offset_right = 200
offset_top = 484 offset_top = 484
offset_bottom = 536 offset_bottom = 536
script = ExtResource("19") script = ExtResource("price_tag")
amount = 35
digit_height = 40.0
[node name="GasBurnerButton" type="Control" parent="UI/BuildMenu"] [node name="GasBurnerButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -785,9 +783,7 @@ offset_left = -60
offset_right = 60 offset_right = 60
offset_top = 774 offset_top = 774
offset_bottom = 826 offset_bottom = 826
script = ExtResource("19") script = ExtResource("price_tag")
amount = 30
digit_height = 40.0
[node name="OutpostButton" type="Control" parent="UI/BuildMenu"] [node name="OutpostButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -827,9 +823,7 @@ offset_left = -60
offset_right = 60 offset_right = 60
offset_top = 1034 offset_top = 1034
offset_bottom = 1086 offset_bottom = 1086
script = ExtResource("19") script = ExtResource("price_tag")
amount = 30
digit_height = 40.0
[node name="PlanningButton" type="Control" parent="UI/BuildMenu"] [node name="PlanningButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -869,9 +863,7 @@ offset_left = -60
offset_right = 60 offset_right = 60
offset_top = 1294 offset_top = 1294
offset_bottom = 1346 offset_bottom = 1346
script = ExtResource("19") script = ExtResource("price_tag")
amount = 70
digit_height = 40.0
[node name="WorkshopButton" type="Control" parent="UI/BuildMenu"] [node name="WorkshopButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 0.5 anchor_left = 0.5
@ -911,9 +903,7 @@ offset_left = -60
offset_right = 60 offset_right = 60
offset_top = 1554 offset_top = 1554
offset_bottom = 1606 offset_bottom = 1606
script = ExtResource("19") script = ExtResource("price_tag")
amount = 55
digit_height = 40.0
[node name="CloseButton" type="Control" parent="UI/BuildMenu"] [node name="CloseButton" type="Control" parent="UI/BuildMenu"]
anchor_left = 1.0 anchor_left = 1.0
@ -982,9 +972,7 @@ offset_left = -70
offset_right = 90 offset_right = 90
offset_top = -424 offset_top = -424
offset_bottom = -334 offset_bottom = -334
script = ExtResource("19") script = ExtResource("price_tag")
amount = 0
digit_height = 78.0
[node name="BuildButton" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"] [node name="BuildButton" type="Control" parent="UI/BuildMenu/InfoPopup/Panel"]
anchor_left = 0.5 anchor_left = 0.5
@ -1850,10 +1838,11 @@ text = "Build a ship"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Bar" type="Control" parent="UI/CraftMenu"] [node name="Bar" type="Control" parent="UI/CraftMenu"]
anchor_right = 1.0
mouse_filter = 2 mouse_filter = 2
offset_left = 24 offset_left = 24
offset_top = 148 offset_top = 148
offset_right = 520 offset_right = -24
offset_bottom = 284 offset_bottom = 284
script = ExtResource("24") script = ExtResource("24")
@ -2433,25 +2422,25 @@ anchor_top = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = 32.0 offset_left = 32.0
offset_top = -272.0 offset_top = -272.0
offset_right = -32.0 offset_right = -300.0
offset_bottom = -152.0 offset_bottom = -152.0
theme_override_font_sizes/font_size = 34 theme_override_font_sizes/font_size = 34
autowrap_mode = 2 autowrap_mode = 2
horizontal_alignment = 1 horizontal_alignment = 0
text = "" text = ""
[node name="CostNumber" type="Control" parent="UI/SkillMenu"] [node name="CostNumber" type="Control" parent="UI/SkillMenu"]
alignment = 2
mouse_filter = 2 mouse_filter = 2
anchor_left = 0.5 anchor_left = 1.0
anchor_top = 1.0 anchor_top = 1.0
anchor_right = 0.5 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -64.0 offset_left = -288.0
offset_top = -146.0 offset_top = -272.0
offset_right = 76.0 offset_right = -32.0
offset_bottom = -82.0 offset_bottom = -152.0
script = ExtResource("19") script = ExtResource("price_tag")
digit_height = 52.0
[node name="StatusLabel" type="Label" parent="UI/SkillMenu"] [node name="StatusLabel" type="Label" parent="UI/SkillMenu"]
mouse_filter = 2 mouse_filter = 2