From 6a62b6f5f8f43ce20280171fff7c0a4063ee948a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 18 Aug 2026 12:09:44 +0200 Subject: [PATCH] Derive the context action row from the build button, not a fixed y Seating the HUD columns below the resource readout moved the build button from the scene-authored 160 to 288. _layout_context_buttons() then wrote the action row straight back to a hardcoded 324 on the next context change - and it runs on the first frame, so the action button overlapped the build button immediately. Same shape of bug as the overlap that seating fixed: a literal coordinate that meant "directly below the thing above me" and silently stopped being true when that thing moved. It now measures instead: row_top = build_button.position.y + build_button.size.y + HUD_ROW_GAP Both the action button and the expanded context row derive from that, so wherever the build button ends up, the row below follows. tests/test_hud_layout.gd asserts the RELATIONSHIP rather than any coordinate, so a freshly hardcoded y fails whatever value someone picks: every HUD button clears the readout's real bottom, and no two simultaneously-usable buttons overlap - checked at load, collapsed and expanded, which is the path that broke. BuildingActionButton and HangarButton are excluded as a deliberately exclusive pair. Verified to fail against the previous code with the reported symptom: build_button y288..436 overlapping action_button y324. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 19 +++-- tests/test_hud_layout.gd | 130 +++++++++++++++++++++++++++++++++++ tests/test_hud_layout.gd.uid | 1 + 3 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 tests/test_hud_layout.gd create mode 100644 tests/test_hud_layout.gd.uid diff --git a/BuildController.gd b/BuildController.gd index c9bc7d0..3fd7f04 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -197,6 +197,8 @@ var context_actions_expanded: bool = false # Gap between the last resource row and the first row of HUD buttons. const HUD_COLUMN_MARGIN: float = 18.0 +# Vertical gap between one row of HUD buttons and the next. +const HUD_ROW_GAP: float = 16.0 # Seats the top-anchored HUD button columns below the resource readout instead @@ -438,8 +440,17 @@ func _process(delta: float) -> void: func _layout_context_buttons() -> void: # Build is permanently under the resource counter. The contextual berth # directly below it is compact until it has more than one real choice. - action_button.offset_top = 324 - action_button.offset_bottom = 472 + # + # "Directly below it" is MEASURED from the build button rather than + # written down as a y. This used to hardcode 324, which was the right + # answer only while the build button sat at the 160 the scene authored - + # once _seat_hud_columns() started moving the whole block down to clear + # the resource readout, this ran on the next context change and dropped + # the action row straight back on top of the build button. + var row_top: float = build_button.position.y + build_button.size.y + HUD_ROW_GAP + var row_height: float = build_button.size.y + action_button.offset_top = row_top + action_button.offset_bottom = row_top + row_height var buttons: Array = _context_action_buttons() if not context_actions_expanded: for button in buttons: @@ -453,8 +464,8 @@ func _layout_context_buttons() -> void: button.visible = true button.offset_left = 188 + i * 164 button.offset_right = button.offset_left + 148 - button.offset_top = 324 - button.offset_bottom = 472 + button.offset_top = row_top + button.offset_bottom = row_top + row_height func _context_action_buttons() -> Array: diff --git a/tests/test_hud_layout.gd b/tests/test_hud_layout.gd new file mode 100644 index 0000000..8cf4570 --- /dev/null +++ b/tests/test_hud_layout.gd @@ -0,0 +1,130 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_hud_layout.gd +# The HUD's top-anchored button columns must not collide with the resource +# readout, and must not collide with each other. +# +# Both halves are regressions that actually shipped: +# - ResourceReadout builds one row per resource type AT RUNTIME, so the +# scene's authored offset_bottom understated it and the build button, +# at a hardcoded y, ended up underneath the resource list. +# - Seating the columns below the readout then exposed a second hardcoded +# y: _layout_context_buttons() wrote the action row back to 324 on the +# next context change, dropping it on top of the moved build button. +# +# Both were a literal coordinate that encoded "below the thing above me" and +# stopped being true when the thing above moved. So this asserts the +# RELATIONSHIP rather than any particular coordinate - a fresh hardcoded y +# fails here whatever value someone picks. +# +# Covers: +# A. every HUD button clears the resource readout's real bottom +# B. no two simultaneously-usable buttons overlap each other +# C. both still hold after _layout_context_buttons() runs, collapsed and +# expanded - the path that broke +# D. it holds with MORE resource types than the scene was authored for, +# which is the direction this drifts + +var scene: Node +var controller: Node +var readout: Node +var stage: int = 0 +var elapsed: float = 0.0 + +# Deliberately share a row: a building either offers its own action or a +# hangar, never both, so their rects coinciding is the design. +const EXCLUSIVE_PAIRS: Array = [["building_action_button", "hangar_button"]] + + +func _initialize() -> void: + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + readout = scene.get_node("UI/ResourceReadout") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + if not _check("as laid out at load"): + return true + + # --- C. the path that broke it ------------------------------ + controller.context_actions_expanded = false + controller._layout_context_buttons() + if not _check("after _layout_context_buttons() collapsed"): + return true + + controller.context_actions_expanded = true + controller._layout_context_buttons() + if not _check("after _layout_context_buttons() expanded"): + return true + + print("TEST_HUD_LAYOUT: PASS") + quit(0) + return true + return false + + +func _buttons() -> Dictionary: + return { + "build_button": controller.build_button, + "transfer_button": controller.transfer_button, + "action_button": controller.action_button, + "landing_button": controller.landing_button, + "building_action_button": controller.building_action_button, + "hangar_button": controller.hangar_button, + } + + +func _check(context: String) -> bool: + var buttons: Dictionary = _buttons() + var names: Array = buttons.keys() + + # --- A. clear of the resource readout --------------------------------- + # content_bottom() is the readout's REAL extent, from the live resource + # list - not the stale height the scene happens to declare. + var readout_bottom: float = readout.content_bottom() + for name in names: + var button: Control = buttons[name] + if button == null: + continue + if button.position.y < readout_bottom: + return _fail("%s sits at y %.0f, inside the resource readout which reaches %.0f (%s)" \ + % [name, button.position.y, readout_bottom, context]) + + # --- B. clear of each other ------------------------------------------- + for i in range(names.size()): + for j in range(i + 1, names.size()): + var a_name: String = names[i] + var b_name: String = names[j] + if _is_exclusive(a_name, b_name): + continue + var a: Control = buttons[a_name] + var b: Control = buttons[b_name] + if a == null or b == null: + continue + if a.get_rect().intersects(b.get_rect()): + return _fail("%s %s overlaps %s %s (%s)" \ + % [a_name, a.get_rect(), b_name, b.get_rect(), context]) + print("clear of the readout (reaches %.0f) and of each other - %s" % [readout_bottom, context]) + return true + + +func _is_exclusive(a: String, b: String) -> bool: + for pair in EXCLUSIVE_PAIRS: + if (a == pair[0] and b == pair[1]) or (a == pair[1] and b == pair[0]): + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_HUD_LAYOUT: FAIL (%s)" % message) + quit(1) + return false diff --git a/tests/test_hud_layout.gd.uid b/tests/test_hud_layout.gd.uid new file mode 100644 index 0000000..0ca49ed --- /dev/null +++ b/tests/test_hud_layout.gd.uid @@ -0,0 +1 @@ +uid://65cagevj7qbj