diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 1b36ec5..bf067ac 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -680,56 +680,101 @@ func _planet_under_fleet() -> Node2D: # The build menu's grid. # # #136: rows used to be a written-down list of tops, read with -# `tops[mini(row, tops.size() - 1)]`. That clamp is the bug the ticket -# reported: with every structure researched the menu wanted a fourth row and -# the list had three, so the last entry was placed on the third row's centre -# slot - the gas burner drawn exactly on top of the transport hub. A list -# that silently stacks whatever will not fit in it is not a layout. +# `tops[mini(row, tops.size() - 1)]`. That clamp silently stacked whatever +# would not fit - the gas burner drawn exactly on top of the transport hub. # -# So rows are COMPUTED from a first top and a pitch, and the pitch is -# tightened when the band is not tall enough - never below a row's own -# height, which is the guarantee that two rows can no longer occupy the same -# space whatever the catalog grows to. Columns widen for the same reason: -# a count that would need more rows than the band holds gets another column -# instead of another row. +# #144: and the band the rows live in was written down too, as a pair of +# constants measured against a 1280-tall canvas. The header above it is +# anchored to the TOP of the panel and the grid is anchored to its CENTRE, +# so the gap between them is half the canvas height - which means the +# constants were only ever right at one window shape. At 1080 tall the grid +# started 4px ABOVE the status line and drew straight through it, which is +# what the ticket reported. +# +# So nothing about the geometry is written down against a screen size any +# more. The band is measured from the header that is actually there, the row +# pitch closes up to fit it (never below a row's own height, which is what +# makes overlapping impossible rather than unlikely), and the column count +# rises until the rows fit - a short panel gets a wide grid instead of one +# that runs off the bottom of it. const MENU_BUTTON_HALF: float = 64.0 const MENU_BUTTON_H: float = 100.0 const MENU_COST_HALF: float = 74.0 const MENU_COST_H: float = 44.0 const MENU_LABEL_OFFSET: float = 106.0 +const MENU_LABEL_GAP: float = 4.0 -# Per column-count geometry. The two-column figures are the ones the menu has -# always used; the three-column ones came with the wide mode; four columns is -# what ten structures now need to stay inside three rows. -const MENU_COL_X: Dictionary = {2: 140.0, 3: 224.0, 4: 258.0} -const MENU_LABEL_HALF: Dictionary = {2: 150.0, 3: 108.0, 4: 84.0} -const MENU_LABEL_H: Dictionary = {2: 52.0, 3: 68.0, 4: 68.0} -const MENU_COST_OFFSET: Dictionary = {2: 162.0, 3: 178.0, 4: 178.0} -const MENU_ROW_PITCH: Dictionary = {2: 223.0, 3: 245.0, 4: 245.0} +# The widest a column is ever given, however much room there is: past this +# the entries stop reading as a grid and start reading as a scattering. +const MENU_NATURAL_SPACING: float = 280.0 +# The narrowest a column may be squeezed to - a button plus a little air. +const MENU_MIN_SPACING: float = MENU_BUTTON_HALF * 2.0 + 12.0 +# A caption gets most of its column, not all of it. +const MENU_LABEL_SHARE: float = 0.46 +# Below this a caption is assumed to need two lines. +const MENU_ONE_LINE_HALF: float = 120.0 +const MENU_LABEL_H_ONE: float = 52.0 +const MENU_LABEL_H_TWO: float = 68.0 -# The band the grid may occupy, in the menu's own centre-anchored space: from -# under the status line (which sits below the resource bar added in #137) to -# just short of the bottom of a 1280-tall screen. -const MENU_GRID_TOP: float = -252.0 -const MENU_GRID_BOTTOM: float = 612.0 +const MENU_SIDE_MARGIN: float = 24.0 +const MENU_HEADER_GAP: float = 16.0 +const MENU_BOTTOM_MARGIN: float = 28.0 +const MENU_MAX_COLUMNS: int = 6 + + +# The vertical band the grid may occupy, in the menu's own centre-anchored +# space: (top, bottom). +# +# MEASURED, not written down (#144). The header is anchored to the top of the +# panel and the grid to its centre, so the two only agree at one canvas +# height - and the pair of constants this replaces happened to be the ones +# for 1280. Reading the status line's own bottom edge and the panel's own +# height makes the answer right at every window shape, including the one the +# player is actually running. +func menu_band() -> Vector2: + var half: float = build_menu.size.y * 0.5 + if half <= 0.0: + half = 640.0 + var header: float = build_status.offset_bottom + MENU_HEADER_GAP + return Vector2(header - half, half - MENU_BOTTOM_MARGIN) + + +# How wide one column is given. Never wider than looks like a grid, never +# narrower than a button, and otherwise whatever the panel has room for. +func menu_column_spacing(columns: int) -> float: + var room: float = build_menu.size.x - MENU_SIDE_MARGIN * 2.0 + if room <= 0.0: + room = 672.0 + return maxf(MENU_MIN_SPACING, minf(MENU_NATURAL_SPACING, room / float(columns))) + + +func menu_label_half(columns: int) -> float: + return menu_column_spacing(columns) * MENU_LABEL_SHARE + + +func menu_label_height(columns: int) -> float: + return MENU_LABEL_H_ONE if menu_label_half(columns) >= MENU_ONE_LINE_HALF \ + else MENU_LABEL_H_TWO + + +func menu_cost_offset(columns: int) -> float: + return MENU_LABEL_OFFSET + menu_label_height(columns) + MENU_LABEL_GAP # How tall one row is, end to end - the cost strip is the last thing in it. func menu_row_height(columns: int) -> float: - return float(MENU_COST_OFFSET[columns]) + MENU_COST_H - - -# The fewest columns that keep the grid inside MENU_MAX_ROWS. Two is the shape -# the menu is meant to have; the wider ones are what a growing catalog forces. -const MENU_MAX_ROWS: int = 4 + return menu_cost_offset(columns) + MENU_COST_H +# The fewest columns whose rows fit the band. Two is the shape the menu is +# meant to have; wider is what a full catalog - or a short window - forces. func menu_columns(count: int) -> int: - for columns in [2, 3, 4]: - var rows: int = int(ceil(float(count) / float(columns))) - if rows <= MENU_MAX_ROWS and _rows_fit(rows, columns): + var widest: int = maxi(mini(MENU_MAX_COLUMNS, + int(floor((build_menu.size.x - MENU_SIDE_MARGIN * 2.0) / MENU_MIN_SPACING))), 2) + for columns in range(2, widest + 1): + if _rows_fit(int(ceil(float(count) / float(columns))), columns): return columns - return 4 + return widest func menu_rows(count: int) -> int: @@ -738,22 +783,24 @@ func menu_rows(count: int) -> int: func _rows_fit(rows: int, columns: int) -> bool: - if rows <= 1: - return true var height: float = menu_row_height(columns) - return (MENU_GRID_BOTTOM - MENU_GRID_TOP - height) / float(rows - 1) >= height + var band: Vector2 = menu_band() + if rows <= 1: + return band.y - band.x >= height + return (band.y - band.x - height) / float(rows - 1) >= height -# Where one row starts. Top-anchored under the header, with the pitch closed -# up when the band is tight - but never below the row height, so two rows -# cannot land on each other however many structures there are (#136). +# Where one row starts. Top of the band, with the pitch closed up when it is +# tight - but never below the row height, so two rows cannot land on each +# other however many structures there are, or however short the window (#136, +# #144). func menu_row_top(row: int, rows: int, columns: int) -> float: var height: float = menu_row_height(columns) - var pitch: float = float(MENU_ROW_PITCH[columns]) + var band: Vector2 = menu_band() + var pitch: float = MENU_NATURAL_SPACING if rows > 1: - pitch = minf(pitch, - (MENU_GRID_BOTTOM - MENU_GRID_TOP - height) / float(rows - 1)) - return MENU_GRID_TOP + maxf(pitch, height) * float(row) + pitch = minf(pitch, (band.y - band.x - height) / float(rows - 1)) + return band.x + maxf(pitch, height) * float(row) func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void: @@ -780,10 +827,10 @@ func _layout_build_menu() -> void: var columns: int = menu_columns(shown.size()) var rows: int = menu_rows(shown.size()) - var col_x: float = float(MENU_COL_X[columns]) - var label_half: float = float(MENU_LABEL_HALF[columns]) - var label_h: float = float(MENU_LABEL_H[columns]) - var cost_offset: float = float(MENU_COST_OFFSET[columns]) + var spacing: float = menu_column_spacing(columns) + var label_half: float = menu_label_half(columns) + var label_h: float = menu_label_height(columns) + var cost_offset: float = menu_cost_offset(columns) for i in range(shown.size()): var row: int = i / columns @@ -791,8 +838,7 @@ func _layout_build_menu() -> void: # read as a row with something missing beside it. var in_row: int = mini(shown.size() - row * columns, columns) var col: int = i % columns - var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * col_x * 2.0 \ - / maxf(float(columns) - 1.0, 1.0) + var cx: float = (float(col) - (float(in_row) - 1.0) * 0.5) * spacing var top: float = menu_row_top(row, rows, columns) _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) _place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h) @@ -1633,7 +1679,13 @@ func _layout_hangar() -> void: # box barely tall enough for one line, so the second line was clipped and # a "Gas Bomber" read as "Gas". var slot_size: float = minf(cell_height * 0.46, cell_width * 0.40) - var label_height: float = maxf(cell_height - slot_size - HANGAR_LABEL_GAP, 1.0) + # #143: TWO gaps, not one - above the caption and below it. With a single + # gap the box ended exactly HANGAR_LABEL_GAP past the bottom of its own + # cell, which is inside the next row's icon. Two pixels of overhang is + # not much on its own, but it is the difference between "the caption + # cannot reach the row below" and "the caption starts in it". + var label_height: float = maxf( + cell_height - slot_size - HANGAR_LABEL_GAP * 2.0, 1.0) _layout_hangar_section(hangar_available_slots, hangar_available_labels, top, columns, cell_width, cell_height, slot_size, label_height, width) @@ -1673,11 +1725,40 @@ func _layout_hangar_section(slots: Array, labels: Array, top: float, columns: in slot.position = Vector2(cell_x + (cell_width - slot_size) * 0.5, cell_y) slot.size = Vector2(slot_size, slot_size) var label: Label = labels[i] - label.position = Vector2(cell_x, cell_y + slot_size + 4.0) + label.position = Vector2(cell_x, cell_y + slot_size + HANGAR_LABEL_GAP) label.size = Vector2(cell_width, label_height) label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_TOP label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + # #143: a caption is "Gas Bomber\n2 available" - two lines before + # autowrap, three after it in a narrow column - at a font size fixed + # in the scene. A Label does not clip by default, so the third line + # was drawn straight over the icon of the row below it. + # + # Two things, and both are wanted. The size is fitted to the box the + # layout above worked out, so the text has somewhere to go; and the + # box clips, so however the catalog grows - a longer vessel name, a + # fourth line - nothing can ever reach the next row again. The first + # is what keeps it legible, the second is what keeps it correct. + label.add_theme_font_size_override("font_size", + _hangar_caption_size(label_height)) + label.clip_text = true + + +# The largest caption that still fits HANGAR_CAPTION_LINES lines into the +# height the grid could spare, within what is readable at arm's length. +const HANGAR_CAPTION_LINES: float = 3.0 +const HANGAR_CAPTION_MIN: int = 13 +const HANGAR_CAPTION_MAX: int = 22 +# Godot lays a line out taller than its nominal font size; measured against +# the project font rather than guessed, this is the ratio that keeps three +# lines inside the box. +const HANGAR_LINE_RATIO: float = 1.25 + + +func _hangar_caption_size(label_height: float) -> int: + return clampi(int(label_height / (HANGAR_CAPTION_LINES * HANGAR_LINE_RATIO)), + HANGAR_CAPTION_MIN, HANGAR_CAPTION_MAX) func _refresh_hangar() -> void: diff --git a/core/GameState.gd b/core/GameState.gd index e10c812..35ea84c 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -2097,6 +2097,24 @@ func has_building_of_type(type: String) -> bool: return false +# Whether a structure of this type exists ANYWHERE, however it is stored. +# +# #145: extractors are not in `buildings`. They are placed onto a planet from +# space and live in `extractors`, with their own position, their own stock +# and their own id - so has_building_of_type("extractor") is false no matter +# how many the player has built. The quest that asks for one (#133) was +# asking a question that could never be answered yes, and no amount of +# building extractors was going to finish it. +# +# Anything asking "does the player have one of these" wants THIS, and +# has_building_of_type() only where the answer really is about the +# `buildings` list itself. +func structure_exists(type: String) -> bool: + if type == BUILDING_EXTRACTOR: + return not extractors.is_empty() + return has_building_of_type(type) + + func remove_buildings_of_type(type: String) -> int: var kept: Array = [] var removed_hub_ids: Array = [] @@ -3032,7 +3050,7 @@ func quest_progress(quest: Dictionary) -> Vector2i: return Vector2i(mini(available_resource(String(goal.get("resource", RESOURCE_ORE))), want), want) QUESTS.GOAL_BUILDING: - return Vector2i(1 if has_building_of_type(String(goal.get("building", ""))) else 0, 1) + return Vector2i(1 if structure_exists(String(goal.get("building", ""))) else 0, 1) QUESTS.GOAL_FLEET_VESSEL: var need: int = int(goal.get("count", 1)) return Vector2i(mini(vessel_count_in_fleet(String(goal.get("vessel", ""))), diff --git a/tests/test_build_menu_hides_locked.gd b/tests/test_build_menu_hides_locked.gd index be54698..ca09841 100644 --- a/tests/test_build_menu_hides_locked.gd +++ b/tests/test_build_menu_hides_locked.gd @@ -20,6 +20,10 @@ extends SceneTree # 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 +# 7. #144 - and that holds at any window shape. The header is anchored to +# the top of the panel and the grid to its centre, so the gap between +# them is half the panel height - it was a constant measured at 1280, +# and at 1080 the grid started above the status line and drew through it var game_state: Node var scene: Node @@ -122,8 +126,8 @@ func _process(delta: float) -> bool: print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)") quit(1) return true - var col_x: float = float(controller.MENU_COL_X[columns]) - var want_x: float = -col_x if i % 2 == 0 else col_x + var spacing: float = controller.menu_column_spacing(columns) + var want_x: float = -spacing * 0.5 if i % 2 == 0 else spacing * 0.5 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) @@ -154,6 +158,49 @@ func _process(delta: float) -> bool: print("%d structures on offer, in %d columns, nothing overlapping" \ % [all_shown.size(), controller.menu_columns(all_shown.size())]) + # --- 7. #144: and at any window shape ------------------------ + # The grid is anchored to the panel's CENTRE and the status line + # above it to the panel's TOP, so the gap between them is half + # the panel height. It used to be a written-down constant, right + # only at 1280 - at 1080 the grid started above the status line + # and drew through it, which is what the ticket reported. + var menu: Control = scene.get_node("UI/BuildMenu") + var status: Control = scene.get_node("UI/BuildMenu/StatusLabel") + var restore: float = menu.offset_bottom + for panel_height in [900.0, 1080.0, 1280.0, 1600.0]: + menu.anchor_bottom = 0.0 + menu.offset_top = 0.0 + menu.offset_bottom = panel_height + controller._refresh_build_status() + var half: float = menu.size.y * 0.5 + # The status line's own bottom, in the grid's centre-anchored + # frame - which is the comparison that was never made. + var header_bottom: float = status.offset_bottom - half + var placed: Array = _shown() + for a in range(placed.size()): + for part in [1, 2, 3]: + var box: Rect2 = _rect(placed[a][part]) + if box.position.y < header_bottom: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s is drawn at y=%.0f, above the status line at %.0f)" \ + % [panel_height, placed[a][0], box.position.y, header_bottom]) + quit(1) + return true + for b in range(a + 1, placed.size()): + for part2 in [1, 2, 3]: + if _rect(placed[a][part2]).intersects(_rect(placed[b][part2])): + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (at %.0fpx tall, %s and %s overlap)" \ + % [panel_height, placed[a][0], placed[b][0]]) + quit(1) + return true + print(" %.0fpx tall: %d columns, top row at %.0f, clear of the status line at %.0f" \ + % [panel_height, controller.menu_columns(placed.size()), + _rect(placed[0][1]).position.y, header_bottom]) + menu.anchor_bottom = 1.0 + menu.offset_top = 0.0 + menu.offset_bottom = restore + controller._refresh_build_status() + print("the grid clears the header at every window shape") + # --- 5. researched but not usable is shown, greyed ---------- var extractor_shown: bool = false for e in shown2: diff --git a/tests/test_menu_polish.gd b/tests/test_menu_polish.gd index 9db3f2a..b2640a0 100644 --- a/tests/test_menu_polish.gd +++ b/tests/test_menu_polish.gd @@ -10,6 +10,8 @@ extends SceneTree # A. #111 - the craft menu names no resources: prices are glyphs, and # every vessel gets a caption that does not overlap its neighbour # B. #111 - and a price strip really carries the vessel's cost +# D. #143 - a hangar caption fits the box it was given and cannot draw +# outside it, so it never lands on the icon of the row below # C. #120 - the hangar's captions have room for two lines, and there is a # rule between what is parked and what is flying # D. #112 - every skill node has a mark, and a node that unlocks a @@ -124,6 +126,38 @@ func _process(delta: float) -> bool: print("hangar: %.0fpx captions, divider at %.0f between %.0f and %.0f" \ % [caption_height, controller.hangar_divider_y, available_bottom, fleet_top]) + # --- D. #143 - and the caption stays in its box -------------- + # The caption is two lines before autowrap and three after it in + # a narrow column, at a size fixed in the scene - so the third + # line was drawn over the icon of the row below. It is fitted to + # the box now, and the box clips, so nothing can reach the next + # row however the vessel names grow. + controller._refresh_hangar() + for pair in [[controller.hangar_available_labels, controller.hangar_available_slots], + [controller.hangar_fleet_labels, controller.hangar_fleet_slots]]: + for i in range(pair[0].size()): + var caption: Label = pair[0][i] + if not caption.clip_text: + return _fail("hangar caption %d can draw outside its box" % i) + var font: Font = caption.get_theme_font("font") + var size_px: int = caption.get_theme_font_size("font_size") + var needed: float = font.get_multiline_string_size(caption.text, + HORIZONTAL_ALIGNMENT_CENTER, caption.size.x, size_px).y + if needed > caption.size.y + 0.5: + return _fail("caption \"%s\" needs %.0fpx at size %d and has %.0f" \ + % [caption.text.replace("\n", " / "), needed, size_px, caption.size.y]) + # And the box itself must not reach the row below it. + for slot in pair[1]: + var below: Rect2 = Rect2(slot.position, slot.size) + if below.position.y > caption.position.y \ + and Rect2(caption.position, caption.size).intersects(below): + return _fail("caption \"%s\" runs into the icon below it" \ + % caption.text.replace("\n", " / ")) + var probe: Label = controller.hangar_available_labels[0] + print("captions fitted to %dpx and clipped; \"%s\" fits its %.0fpx box" \ + % [probe.get_theme_font_size("font_size"), + probe.text.replace("\n", " / "), probe.size.y]) + # --- D. every skill node has a mark ----------------------- var tree_view: Control = scene.get_node("UI/SkillMenu/Tree") var marked: int = 0 diff --git a/tests/test_quest_chain.gd b/tests/test_quest_chain.gd index b631bed..8cf2aa3 100644 --- a/tests/test_quest_chain.gd +++ b/tests/test_quest_chain.gd @@ -29,6 +29,8 @@ extends SceneTree # P. #142 - enough rocks are asked for before the first structure is # Q. #141 - the first structure that needs a doctrine has that doctrine as # a quest of its own, so research is taught rather than assumed +# R. #145 - every structure quest is actually finishable: building the +# thing it asks for, the way the game builds it, completes it # I. lines survive having nobody to say them: a quest finished with no # banner alive is read out by the next one that is # J. a planet surface carries the chain too - a quest finished down there @@ -126,6 +128,36 @@ func _catalogue_cases() -> bool: return _fail("the quest after the mothership is %s, not exploration" % after.id) print("the mothership answers with distance, and exploration follows it") + # --- R. #145: every structure quest can actually be finished --------- + # The extractor quest could not be. Extractors are placed onto a planet + # from space and live in their own array, not in `buildings`, so the + # question the goal asked ("is there a building of this type") was one + # that could never be answered yes however many were built. + # + # Checked by BUILDING each one the way the game builds it and watching + # the goal flip, rather than by knowing which array it lands in - the + # knowing is what got this wrong in the first place. + for i in range(QUESTS.count()): + var build_quest: Dictionary = QUESTS.at(i) + if String(build_quest.goal.kind) != QUESTS.GOAL_BUILDING: + continue + var type: String = String(build_quest.goal.building) + game_state.buildings.clear() + game_state.extractors.clear() + if game_state.quest_goal_met(build_quest): + return _fail("quest %s counts as done with nothing built at all" % build_quest.id) + if type == GameState.BUILDING_EXTRACTOR: + game_state.add_extractor("Quest Test", Vector2.ZERO, GameState.RESOURCE_ORE, + "quest:costs") + else: + game_state.add_building(type, Vector2.ZERO) + if not game_state.quest_goal_met(build_quest): + return _fail("building a %s does not finish quest %s - it cannot be completed at all" \ + % [type, build_quest.id]) + game_state.buildings.clear() + game_state.extractors.clear() + print("every structure quest is finished by building the thing it asks for") + # --- P. #142: enough rocks before anything is asked to be built ------ # The first structure the chain asks for used to arrive straight after a # five-ore errand, costing four times that, with nothing in between