#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

@ -20,7 +20,9 @@ var controller: Node
var popup: Control
var title: 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 stage: int = 0
var elapsed: float = 0.0
@ -89,11 +91,12 @@ func _process(delta: float) -> bool:
var informed: bool = popup.visible \
and title.text == option.title \
and message.text.length() > 40 \
and cost_number.amount == expected_cost \
and cost_number.priced(GameState.RESOURCE_ORE) == expected_cost \
and build_button.visible
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" % [
type, popup.visible, title.text, cost_number.amount,
type, popup.visible, title.text,
cost_number.priced(GameState.RESOURCE_ORE),
build_button.visible, 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
# 5. researched-but-not-usable is still shown greyed (an extractor with no
# 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 scene: Node
@ -45,6 +48,12 @@ func _shown() -> Array:
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:
# (row, x) of every visible button, in menu order.
var out: Array = []
@ -100,21 +109,51 @@ func _process(delta: float) -> bool:
# --- 3. consecutive slots, no holes -------------------------
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()):
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:
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)")
quit(1)
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:
print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (entry %d at x=%.1f, expected %.1f)" % [i, slots[i][1], want_x])
quit(1)
return true
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 ----------
var extractor_shown: bool = false
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):
return _fail("a node was bought with no ore at all")
controller._refresh_skill_menu(second)
if controller.skill_status.text == "":
return _fail("an unaffordable node was refused silently")
print("unaffordable node refused, and says so: '%s'" % controller.skill_status.text)
# #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():
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")
quit(0)