From 693919dadbe2b8daa9600eccef5f18a13e92a51d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 22:31:56 +0200 Subject: [PATCH] Let a dialog opened over another dialog be closed The "no research data" explainer is the only dialog in the game that opens ON TOP of another one - the skill tree or the ship lab, from the button that was just refused. IconButton._blocked_by_modal() refuses input to any button sitting behind an open panel, and it had no notion of stacking order: it saw the lab still visible, saw it was not an ancestor of the popup's close button, and killed the touch. Every button in the explainer was dead, including the only way out of it. Reported from the game. The check now compares draw order. A panel that this button's own panel is drawn on top of is behind it, not over it, and is ignored; a panel drawn above it still blocks, so the lab's own buttons stay dead while the popup covers them. Buttons belonging to no panel - the HUD - are unchanged and still blocked by anything open. The existing test missed this because it dismissed the popup by calling close_menu() directly, which is not what a player can do. Case D presses the button, and asserts the reverse too. Verified in both directions: with the fix reverted it fails with "the explainer close button is blocked by the lab behind it - the dialog is inescapable". 172/172. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- tests/test_research_points_explainer.gd | 28 +++++++++++++++++++ ui/IconButton.gd | 36 +++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/test_research_points_explainer.gd b/tests/test_research_points_explainer.gd index aaef71a..6fc638e 100644 --- a/tests/test_research_points_explainer.gd +++ b/tests/test_research_points_explainer.gd @@ -113,6 +113,10 @@ func _process(delta: float) -> bool: break if game_state.research_point_cost(String(options[controller.lab_choice].id)) <= 0: return _fail("no lab option costs research data, so this proves nothing") + # The lab panel is open when the player presses buy - that is what + # puts the explainer on top of another dialog, which is the whole + # point of case D below. + controller.lab_menu.open_menu() controller._on_lab_buy() if not popup.visible: return _fail("the lab refused for want of data with no explanation") @@ -120,6 +124,30 @@ func _process(delta: float) -> bool: return _fail("the lab still blames the queue: '%s'" % controller.lab_menu.status.text) print("ship lab: refused and explained, not blamed on the queue") + # --- D. and the player can get back out of it ---------------- + # The explainer is the only dialog that opens ON TOP of another + # one, and IconButton refuses input to any button sitting behind + # an open panel. That used to include this popup's own close + # button, so the dialog could not be dismissed at all. Case B + # above left the ship lab open underneath, which is the real + # situation - assert that rather than arranging it. + if not controller.lab_menu.visible: + return _fail("the lab is not open underneath, so this proves nothing") + var close: Control = popup.get_node("CloseButton") + if close._blocked_by_modal(): + return _fail("the explainer close button is blocked by the lab behind it - the dialog is inescapable") + close.pressed.emit() + if popup.visible: + return _fail("pressing close left the explainer open") + # The reverse must still hold: the lab's own buttons stay dead + # while a dialog covers them. + popup.open_for_shortfall(10, "anything") + var lab_close: Control = controller.lab_menu.get_node("CloseButton") + if not lab_close._blocked_by_modal(): + return _fail("the lab buttons are live underneath an open dialog") + popup.close_menu() + print("close button works over the lab, and the lab stays blocked underneath") + print("TEST_RESEARCH_POINTS_EXPLAINER: PASS") quit(0) return true diff --git a/ui/IconButton.gd b/ui/IconButton.gd index 75ca542..c6af89c 100644 --- a/ui/IconButton.gd +++ b/ui/IconButton.gd @@ -130,6 +130,7 @@ func _blocked_by_modal() -> bool: # touches through the GUI system swallows them before they ever get # there. So the panels stay transparent to input and the buttons check # instead. + var own_modal: Node = _own_modal() for menu in get_tree().get_nodes_in_group("modal_menus"): # is_visible_in_tree(), NOT visible. Hiding a parent leaves the # child's own `visible` flag true, and the build menu's info popup is @@ -137,11 +138,42 @@ func _blocked_by_modal() -> bool: # invisible popup still claiming to be open, and every button in the # game was blocked from then on. That is not a state a player can # get out of. - if menu.is_visible_in_tree() and not menu.is_ancestor_of(self): - return true + if not menu.is_visible_in_tree(): + continue + if menu.is_ancestor_of(self): + continue + # Stacking order matters, and used not to be considered at all. A + # panel that this button's own panel is drawn ON TOP OF sits behind + # it, not over it, so it must not swallow the input. The research + # shortfall popup opens over the skill tree and the ship lab, and + # without this every button in it - its close button included - was + # dead, leaving the player no way out of the dialog. + if own_modal != null and not _covers(menu, own_modal): + continue + return true return false +# The modal panel this button lives inside, or null for a HUD button that +# belongs to no panel (those stay blocked by any open panel, as before). +func _own_modal() -> Node: + var node: Node = get_parent() + while node != null: + if node.is_in_group("modal_menus"): + return node + node = node.get_parent() + return null + + +func _covers(other: Node, own: Node) -> bool: + # Later siblings draw on top. Different parents means one of them is + # nested inside something else - the build menu's info popup is a child + # of the build menu - and there the conservative answer is "covered". + if other.get_parent() != own.get_parent(): + return true + return other.get_index() > own.get_index() + + func _gui_input(event: InputEvent) -> void: # @tool runs this in the editor too, where a click on the canvas would # emit `pressed` at whatever it is wired to. The glyph is what we want