diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index c765963..42cd7e6 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -63,6 +63,7 @@ var context_actions_expanded: bool = false func _ready() -> void: + get_viewport().size_changed.connect(_on_viewport_resized) _seat_hud_columns() build_button.pressed.connect(_on_build_pressed) action_button.pressed.connect(_on_context_action_pressed) @@ -97,6 +98,22 @@ func _ready() -> void: _layout_context_buttons() +# HudScale reapplies each control's authored offsets whenever the window +# changes, which wipes the two layouts this controller writes at runtime. +# Re-running them after a resize is what keeps a desktop window that gets +# dragged to another size from leaving the context row where it was. +func _on_viewport_resized() -> void: + # Deferred: HudScale reapplies on the same signal, and which of two + # siblings connected first is not a thing to depend on. By the end of the + # frame its offsets are in, and these two can seat against them. + _relayout_hud.call_deferred() + + +func _relayout_hud() -> void: + _seat_hud_columns() + _layout_context_buttons() + + func _seat_hud_columns() -> void: var readout: ResourceReadout = get_node_or_null("../UI/ResourceReadout") if readout == null: @@ -109,7 +126,10 @@ func _seat_hud_columns() -> void: highest = minf(highest, button.position.y) if is_inf(highest): return - var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN - highest + # The margin is authored in the same canvas units as everything else, so + # it scales with the HUD (#228) or it would be the one gap that stayed + # phone-sized on a desktop. + var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN * readout.scale.y - highest if shift <= 0.0: return for button in buttons: @@ -222,7 +242,14 @@ func _clear_detected_context() -> void: func _layout_context_buttons() -> void: - var row_top: float = build_button.position.y + build_button.size.y + HUD_ROW_GAP + # position is in the UI layer's space and size is in the button's own, + # and since #228 those differ by the HUD scale on a short canvas. Every + # gap and stride below is therefore scaled, while the RECTS keep their + # authored size - HudScale renders them down rather than shrinking them. + # Mixing the two is what this function got wrong twice before (see + # test_hud_layout), and a desktop canvas makes it wrong again. + var k: float = build_button.scale.y + var row_top: float = build_button.position.y + build_button.size.y * k + HUD_ROW_GAP * k var row_height: float = build_button.size.y action_button.offset_top = row_top action_button.offset_bottom = row_top + row_height @@ -234,8 +261,8 @@ func _layout_context_buttons() -> void: for i in range(buttons.size()): var button: Control = buttons[i] button.visible = true - button.offset_left = 188 + i * 164 - button.offset_right = button.offset_left + 148 + button.offset_left = (188.0 + float(i) * 164.0) * k + button.offset_right = button.offset_left + 148.0 button.offset_top = row_top button.offset_bottom = row_top + row_height diff --git a/tests/test_hangar_management.gd b/tests/test_hangar_management.gd index 4233217..135e6c3 100644 --- a/tests/test_hangar_management.gd +++ b/tests/test_hangar_management.gd @@ -83,8 +83,12 @@ func _process(delta: float) -> bool: controller.transfer_button.visible = controller._has_transfer_context() controller._layout_context_buttons() var buttons: Array = [controller.building_action_button, controller.hangar_button] + # Rendered width, not the authored rect: #228 shrinks the HUD on a + # short canvas by scaling the node and leaving its rect alone, so + # offset_right overstates where a button actually ends. for i in range(buttons.size() - 1): - if buttons[i].offset_right > buttons[i + 1].offset_left: + var ends: float = buttons[i].offset_left + buttons[i].size.x * buttons[i].scale.x + if ends > buttons[i + 1].offset_left: _fail("context buttons %d and %d overlap" % [i, i + 1]) return true controller._collapse_context_actions() diff --git a/tests/test_hud_layout.gd b/tests/test_hud_layout.gd index c6f7226..2fa4b8a 100644 --- a/tests/test_hud_layout.gd +++ b/tests/test_hud_layout.gd @@ -31,6 +31,9 @@ var controller: Node var readout: Node var stage: int = 0 var elapsed: float = 0.0 +# A desktop canvas (1080 units) and a phone one (3413), where the HUD +# scale is 0.45 and 1.0 respectively. +var shapes: Array = [Vector2i(1920, 1080), Vector2i(720, 1280)] # Deliberately share a row: a building either offers its own action or a # hangar, never both, so their rects coinciding is the design. @@ -52,23 +55,40 @@ func _process(delta: float) -> bool: return false scene.get_node("AsteroidField").set_process(false) scene.get_node("EnemySpawner").set_process(false) - if not _check("as laid out at load"): + # --- E. at BOTH shapes --------------------------------------- + # #228 shrinks the HUD on a short canvas, so every relationship + # here now has a scale in it. A desktop canvas is what the + # default viewport gives, and the phone is where that scale is + # 1.0 - the arithmetic has to hold at both or one of them is + # being checked by accident. + root.size = shapes.pop_front() + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + elapsed = 0.0 + var label: String = "%.0f-unit canvas" % readout.get_viewport_rect().size.y + if not _check("as laid out at load, %s" % label): 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"): + if not _check("collapsed, %s" % label): return true controller.context_actions_expanded = true controller._layout_context_buttons() - if not _check("after _layout_context_buttons() expanded"): + if not _check("expanded, %s" % label): return true - print("TEST_HUD_LAYOUT: PASS") - quit(0) - return true + if shapes.is_empty(): + print("TEST_HUD_LAYOUT: PASS") + quit(0) + return true + root.size = shapes.pop_front() + return false return false @@ -110,13 +130,21 @@ func _check(context: String) -> bool: var b: Control = buttons[b_name] if a == null or b == null: continue - if a.get_rect().intersects(b.get_rect()): + if _rendered(a).intersects(_rendered(b)): return _fail("%s %s overlaps %s %s (%s)" \ - % [a_name, a.get_rect(), b_name, b.get_rect(), context]) + % [a_name, _rendered(a), b_name, _rendered(b), context]) print("clear of the readout (reaches %.0f) and of each other - %s" % [readout_bottom, context]) return true +# Where a control actually lands, which since #228 is not its layout rect: +# the HUD shrinks on a short canvas by scaling the node and leaving the rect +# at its authored size, so position is in scaled parent units while size is +# in unscaled local ones. +func _rendered(c: Control) -> Rect2: + return Rect2(c.position, c.size * c.scale) + + 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]): diff --git a/tests/test_hud_scales_to_screen.gd b/tests/test_hud_scales_to_screen.gd new file mode 100644 index 0000000..33b8d12 --- /dev/null +++ b/tests/test_hud_scales_to_screen.gd @@ -0,0 +1,118 @@ +extends SceneTree + +# Forgejo #228: the HUD must not be three times larger on a PC than on a +# phone. +# +# The layout is authored in CANVAS units. With stretch "canvas_items" and +# aspect "expand" over a 1920x1080 base, a 720x1280 phone scales by 0.375 and +# gets 1920x3413 canvas units, while ANY 16:9-or-wider screen lands on +# 1920x1080 units however many pixels it really has. The canvas is three +# times shorter, so an unscaled 148-unit button goes from 4.3% of the +# screen's height to 13.7% of it. +# +# Nothing collides - that was checked pair by pair on both canvases and the +# same six pairs overlap on each, all of them same-slot alternatives. The +# fault is purely that everything is enormous. +# +# Covers: +# A. on a phone the HUD is left exactly as authored +# B. on a desktop canvas a control takes a similar FRACTION of the screen +# rather than three times more +# C. the shrink is bounded, so a landscape phone keeps thumb-sized targets +# D. it follows a resize rather than only applying once at startup + +const PHONE: Vector2i = Vector2i(720, 1280) +const DESKTOP: Vector2i = Vector2i(1920, 1080) +const BIG_DESKTOP: Vector2i = Vector2i(2560, 1440) + +var scene: Node +var hud: Node +var button: Control +var frames: int = 0 +var stage: int = 0 +var phone_fraction: float = 0.0 + + +func _initialize() -> void: + var gs: Node = root.get_node("GameState") + gs.buildings.clear() + gs.reset_new_game() + gs.intro_active = false + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + hud = scene.get_node("UI/HudScale") + button = scene.get_node("UI/MapButton") + + +func _fraction() -> float: + # Height of a HUD control as a share of the screen's height - the number + # that actually differs between the two, and the one a player sees. + # Rendered height, not the layout rect: the rect keeps its authored size + # and the node is scaled, so scale is where the shrink actually lives. + return (button.size.y * button.scale.y) / button.get_viewport_rect().size.y + + +func _process(_delta: float) -> bool: + frames += 1 + if frames < 10: + return false + frames = 0 + match stage: + 0: + root.size = PHONE + stage = 1 + return false + 1: + # --- A. authored size on the shape it was authored for ------- + var k: float = hud.hud_scale() + if not is_equal_approx(k, 1.0): + return _fail("the phone HUD is being scaled by %.3f, but it is the authored shape" % k) + phone_fraction = _fraction() + print("phone %s: canvas %.0f tall, scale %.2f, control is %.1f%% of the screen" % [ + PHONE, button.get_viewport_rect().size.y, k, phone_fraction * 100.0]) + root.size = DESKTOP + stage = 2 + return false + 2: + # --- B. a similar share of a desktop screen ------------------ + var pc_fraction: float = _fraction() + var ratio: float = pc_fraction / maxf(phone_fraction, 0.0001) + print("desktop %s: canvas %.0f tall, scale %.2f, control is %.1f%% of the screen (%.2fx the phone)" % [ + DESKTOP, button.get_viewport_rect().size.y, hud.hud_scale(), + pc_fraction * 100.0, ratio]) + if ratio > 1.75: + return _fail("a HUD control is %.2fx its phone share on a desktop screen - that is the bug" % ratio) + # --- C. and not shrunk into uselessness ---------------------- + if hud.hud_scale() < 0.4: + return _fail("the HUD shrank to %.2f, past anything pressable" % hud.hud_scale()) + root.size = BIG_DESKTOP + stage = 3 + return false + 3: + # --- D. follows the resize ----------------------------------- + var big: float = _fraction() + print("big desktop %s: control is %.1f%% of the screen" % [BIG_DESKTOP, big * 100.0]) + if absf(big - phone_fraction) / maxf(phone_fraction, 0.0001) > 0.75: + return _fail("a bigger desktop screen did not keep the control proportional (%.1f%% vs %.1f%%)" \ + % [big * 100.0, phone_fraction * 100.0]) + # Back to the phone: the HUD must return, not stay shrunk. + root.size = PHONE + stage = 4 + return false + 4: + if not is_equal_approx(hud.hud_scale(), 1.0): + return _fail("returning to the phone shape left the HUD at %.3f" % hud.hud_scale()) + if absf(_fraction() - phone_fraction) > 0.001: + return _fail("the HUD did not come back to its authored size on resize") + print("resizing back to the phone restored the authored size") + print("TEST_HUD_SCALES_TO_SCREEN: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_HUD_SCALES_TO_SCREEN: FAIL (%s)" % message) + quit(1) + return true diff --git a/ui/HudScale.gd b/ui/HudScale.gd new file mode 100644 index 0000000..5d306ba --- /dev/null +++ b/ui/HudScale.gd @@ -0,0 +1,78 @@ +extends Node + +# Forgejo #228: the HUD is proportionally three times too big on a PC. +# +# The layout is authored in CANVAS units against a portrait phone. With +# stretch "canvas_items"/"expand" and a 1920x1080 base, a 720x1280 phone +# scales by 0.375 and so gets a canvas 1920x3413 units tall - while ANY +# 16:9-or-wider screen lands on 1920x1080 units whatever its pixel count. +# The canvas is three times shorter, so a 148-unit button goes from 4.3% of +# the screen's height to 13.7% of it. Nothing collides; everything is just +# enormous, and the thumb sticks in particular. +# +# Each HUD element's offsets are measured FROM ITS OWN ANCHOR, so scaling +# them scales the element's size and its distance from the edge it hangs off +# together. That is the whole trick: one multiplier per control, no layout +# rules to re-derive, and anchoring keeps working untouched. + +# The canvas the HUD was drawn for. Not a magic number: 1280 / (720 / 1920). +const AUTHORED_CANVAS_HEIGHT: float = 3413.0 + +# Below this the controls stop being worth pressing. A landscape phone gets +# the same short canvas as a desktop, and there the sticks are still thumb +# targets - so the shrink is bounded rather than purely proportional. +const MIN_SCALE: float = 0.45 + +# Authored offsets and sizes, captured before anything is touched. +# Re-reading them after a resize would measure a previous scaling and let the +# HUD ratchet away to nothing - the same trap NeonPanel documents. +var _authored: Dictionary = {} + + +func _ready() -> void: + var layer: Node = get_parent() + for child in layer.get_children(): + if child == self or not (child is Control): + continue + var c: Control = child + # Full-bleed things - the modal menus, the breach overlay - hang off + # every edge at once and have nothing to shrink. + if is_equal_approx(c.anchor_left, 0.0) and is_equal_approx(c.anchor_right, 1.0) \ + and is_equal_approx(c.anchor_top, 0.0) and is_equal_approx(c.anchor_bottom, 1.0): + continue + _authored[c] = [c.offset_left, c.offset_top, + c.offset_right - c.offset_left, c.offset_bottom - c.offset_top] + get_viewport().size_changed.connect(_apply) + _apply() + + +func hud_scale() -> float: + var height: float = get_viewport().get_visible_rect().size.y + if height <= 0.0: + return 1.0 + # Only ever shrinks. A canvas TALLER than the phone's is not a reason to + # inflate the HUD over the game. + return clampf(height / AUTHORED_CANVAS_HEIGHT, MIN_SCALE, 1.0) + + +func _apply() -> void: + var k: float = hud_scale() + for control in _authored: + if not is_instance_valid(control): + continue + var a: Array = _authored[control] + var c: Control = control + # Position moves toward the edge the element hangs off; the RECT keeps + # its authored size and the node is scaled instead. + # + # Scaling the rect alone was wrong: half the HUD draws its own + # contents at fixed canvas sizes - ResourceReadout lays its rows out + # in code - so a shrunken rect kept full-size content inside it, and + # the build button slid up underneath the resource list. Scaling the + # node takes the drawn content with it. + c.pivot_offset = Vector2.ZERO + c.offset_left = float(a[0]) * k + c.offset_top = float(a[1]) * k + c.offset_right = c.offset_left + float(a[2]) + c.offset_bottom = c.offset_top + float(a[3]) + c.scale = Vector2(k, k) diff --git a/ui/ResourceReadout.gd b/ui/ResourceReadout.gd index a2f6d97..53fbe38 100644 --- a/ui/ResourceReadout.gd +++ b/ui/ResourceReadout.gd @@ -79,7 +79,13 @@ func rows_height() -> float: # Bottom edge of the rows in this Control's parent space - the line anything # stacked below the readout has to clear. func content_bottom() -> float: - return position.y + rows_height() + # Rendered, not laid out. #228 scales the HUD down on a short canvas by + # scaling the NODE while leaving its rect at the authored size, so + # rows_height() is in unscaled local units while position is already in + # scaled parent ones. Mixing the two overstated this by more than a + # button's height and pushed the columns down to clear space that was + # not occupied. + return position.y + rows_height() * scale.y func _add_glyph(resource_type: String, at: Vector2) -> void: diff --git a/world/Main.tscn b/world/Main.tscn index 31147f7..1d70f1b 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -17,6 +17,7 @@ [ext_resource type="Script" path="res://world/Starfield.gd" id="1"] +[ext_resource type="Script" path="res://ui/HudScale.gd" id="hud_scale"] [ext_resource type="Script" path="res://vessels/Fleet.gd" id="2"] [ext_resource type="Script" path="res://controllers/GameController.gd" id="3"] [ext_resource type="Script" path="res://enemies/EnemySpawner.gd" id="4"] @@ -151,6 +152,9 @@ script = ExtResource("12") [node name="UI" type="CanvasLayer" parent="."] layer = 1 +[node name="HudScale" type="Node" parent="UI"] +script = ExtResource("hud_scale") + [node name="ResourceReadout" type="Control" parent="UI"] mouse_filter = 2 offset_left = 28