From 01f053e01a5c1dc6d45c16f5b627ff8cb9f716a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 22 Aug 2026 23:29:44 +0200 Subject: [PATCH] #92: shrink the confirm popup to the window it is in The card is authored at a fixed 704x1200 around a portrait phone. On a window wider than it is tall the stretched canvas is about 1080 units high, so the card sat at y=-60 and hung over both edges - and the confirm and cancel buttons are anchored to its own bottom edge, so they went off screen with it. The popup was not merely ugly on a desktop, it was a dead end: nothing on it could be pressed. NeonPanel now fits itself to the viewport and re-fits when the window changes. It only ever shrinks; growing the card would stretch proportions that are tuned rather than fluid. The fitting adjusts offsets around a fixed centre, so it applies only to a card pinned to a point - a panel stretched between different anchors already follows its parent and is left alone. Verified across portrait, 16:9, ultrawide and small-laptop shapes, on the card and on both buttons. Co-Authored-By: Claude Opus 5 --- tests/test_build_popup_fits_window.gd | 110 ++++++++++++++++++++++ tests/test_build_popup_fits_window.gd.uid | 1 + ui/NeonPanel.gd | 52 ++++++++++ 3 files changed, 163 insertions(+) create mode 100644 tests/test_build_popup_fits_window.gd create mode 100644 tests/test_build_popup_fits_window.gd.uid diff --git a/tests/test_build_popup_fits_window.gd b/tests/test_build_popup_fits_window.gd new file mode 100644 index 0000000..1aa3d1f --- /dev/null +++ b/tests/test_build_popup_fits_window.gd @@ -0,0 +1,110 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_build_popup_fits_window.gd +# Forgejo #92: the confirm-this-building popup ran off a landscape window. +# +# The card is authored at a fixed 704x1200 around a portrait phone. On a +# window that is wider than it is tall the stretched canvas is ~1080 units +# high, so 1200 units of card hung over both edges - and since the confirm +# and cancel buttons are anchored to the card's own bottom edge, they went +# with it. A player on a desktop could open the popup and then neither buy +# nor dismiss the thing. +# +# Covers: +# A. on every window shape the card lies entirely inside the viewport +# B. so do the two buttons on it, which is the part that made the popup a +# dead end rather than merely an ugly one +# C. it is not blown up on a big screen - the layout inside is tuned at +# the authored size and only ever shrinks +# D. it re-fits when the window changes, rather than only at startup + +# Portrait phone, 16:9 desktop, an ultrawide, and a small laptop. +const SHAPES: Array = [ + Vector2i(720, 1280), Vector2i(1920, 1080), Vector2i(2560, 900), Vector2i(1280, 720), +] + +var game_state: Node +var scene: Node +var controller: Node +var panel: Control +var shapes: Array = [] +var frames: int = 0 +var opened: bool = false + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 500 + game_state.buildings.clear() + game_state.extractors.clear() + shapes = SHAPES.duplicate() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + panel = scene.get_node("UI/BuildMenu/InfoPopup/Panel") + + +func _inside(rect: Rect2, outer: Rect2) -> bool: + # Rect2.encloses() is exclusive at the edges for zero-area cases; this is + # the plain reading - all four sides within, touching allowed. + return rect.position.x >= outer.position.x - 0.5 \ + and rect.position.y >= outer.position.y - 0.5 \ + and rect.end.x <= outer.end.x + 0.5 \ + and rect.end.y <= outer.end.y + 0.5 + + +func _process(_delta: float) -> bool: + frames += 1 + if not opened: + if frames < 12: + return false + controller._on_build_pressed() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) + if not scene.get_node("UI/BuildMenu/InfoPopup").visible: + return _fail("the build popup did not open") + opened = true + frames = 0 + root.size = SHAPES[0] + return false + # Give the resize a few frames to reach the Control layout before reading + # any rect off it. + if frames < 6: + return false + frames = 0 + + var shape: Vector2i = shapes.pop_front() + var screen: Rect2 = Rect2(Vector2.ZERO, panel.get_viewport_rect().size) + var card: Rect2 = Rect2(panel.global_position, panel.size) + print("%s -> canvas %.0fx%.0f, card %.0fx%.0f at %.0f,%.0f" % [ + shape, screen.size.x, screen.size.y, card.size.x, card.size.y, + card.position.x, card.position.y]) + + # A. the card itself + if not _inside(card, screen): + return _fail("at %s the card %s is not inside the screen %s" % [shape, card, screen]) + # B. and what the player has to press on it + for button_name in ["BuildButton", "CloseButton"]: + var button: Control = panel.get_node(button_name) + var button_rect: Rect2 = Rect2(button.global_position, button.size) + if not _inside(button_rect, screen): + return _fail("at %s the %s at %s is off screen" % [shape, button_name, button_rect]) + # C. never bigger than authored + if card.size.x > panel.design_size.x + 0.5 or card.size.y > panel.design_size.y + 0.5: + return _fail("at %s the card grew past its authored %s" % [shape, panel.design_size]) + + if shapes.is_empty(): + # D. the last shape was reached by resizing an already-open popup, so + # every check above already ran against a re-fit rather than against + # a card that happened to be built at the right size. + print("TEST_BUILD_POPUP_FITS_WINDOW: PASS (%d window shapes)" % SHAPES.size()) + quit(0) + return true + root.size = shapes[0] + return false + + +func _fail(message: String) -> bool: + print("TEST_BUILD_POPUP_FITS_WINDOW: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_build_popup_fits_window.gd.uid b/tests/test_build_popup_fits_window.gd.uid new file mode 100644 index 0000000..b6c1be6 --- /dev/null +++ b/tests/test_build_popup_fits_window.gd.uid @@ -0,0 +1 @@ +uid://bt0yr2oys1usk diff --git a/ui/NeonPanel.gd b/ui/NeonPanel.gd index 5e212bf..55803ab 100644 --- a/ui/NeonPanel.gd +++ b/ui/NeonPanel.gd @@ -7,6 +7,17 @@ extends Control # hard-edged neon of the buttons and HUD. # # Draws itself from its own rect, so it can be sized freely in the scene. +# +# #92: it also SHRINKS itself to whatever window it is in. The card is +# authored at a fixed size around a portrait phone, and on a landscape +# window - where the canvas is far wider than it is tall - a card taller +# than the screen simply ran off the top and the bottom, taking the confirm +# and cancel buttons with it. The buttons are anchored to the panel's own +# bottom edge, so bringing the edge back on screen brings them back too. +# +# Only ever shrinks. Growing the card on a big screen would blow the +# authored proportions up with it, and the layout inside is tuned, not +# fluid. @export var glow_color: Color = Color(0.25, 0.85, 1.0) @export var core_color: Color = Color(1.0, 1.0, 1.0) @@ -15,6 +26,47 @@ extends Control @export var fill_color: Color = Color(0.02, 0.03, 0.07, 1.0) @export var corner_cut: float = 26.0 @export var bracket_length: float = 46.0 +# Breathing room kept between the card and the edge of the screen, so a +# fitted panel reads as a card on a backdrop rather than as a full-bleed +# page that happens to have a border. +@export var screen_margin: float = 24.0 + +# The size the card was authored at, and the point it is centred on. Read +# once, before any fitting has happened - re-reading them later would +# measure a previous fit and let the card ratchet smaller on every resize. +var design_size: Vector2 = Vector2.ZERO +var design_centre: Vector2 = Vector2.ZERO + + +func _ready() -> void: + # Fitting adjusts the four offsets around a fixed centre, which is only + # meaningful for a card pinned to a point - both anchor pairs collapsed, + # as a centred dialog's are. A panel stretched between different anchors + # already follows its parent and is left alone. + if not (is_equal_approx(anchor_left, anchor_right) \ + and is_equal_approx(anchor_top, anchor_bottom)): + return + design_size = Vector2(offset_right - offset_left, offset_bottom - offset_top) + design_centre = Vector2(offset_left + offset_right, offset_top + offset_bottom) * 0.5 + get_viewport().size_changed.connect(_fit_to_screen) + _fit_to_screen() + + +func _fit_to_screen() -> void: + if design_size == Vector2.ZERO: + return + # The viewport rect, not the window: with stretch/mode "canvas_items" the + # UI is laid out in stretched canvas units, and those are the units these + # offsets are in. + var available: Vector2 = get_viewport_rect().size - Vector2(screen_margin, screen_margin) * 2.0 + var fitted: Vector2 = Vector2( + minf(design_size.x, maxf(available.x, 0.0)), + minf(design_size.y, maxf(available.y, 0.0))) + offset_left = design_centre.x - fitted.x * 0.5 + offset_right = design_centre.x + fitted.x * 0.5 + offset_top = design_centre.y - fitted.y * 0.5 + offset_bottom = design_centre.y + fitted.y * 0.5 + queue_redraw() func _draw() -> void: