From 34e252037e1ede208be3ce1b3ee635d249c7efa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 22:27:00 -0400 Subject: [PATCH] #231, #230: a key per ability, and an upkeep sheet that adds up #231 - the abilities were hard to use on a keyboard. "The currently selected weapons" names a real mechanism: MouseCombatController (#80) has number keys that SELECT which weapon a click fires. That is two acts for one shot, and the selection is invisible unless you look at the buttons. Q, E and space now fire the gas bomb, the laser and the dash outright, wherever the mouse already points. It goes through fire_at(), the same public entry the click path uses, so every guard comes with it - no hull ready, cannot afford the shot, a panel over the button. The number keys stay: they are not what this ticket is about, and removing them would take the click path's abilities with them. Q and E were already taken - they pivot the formation - so the pivot pair moved to Z and C, one row under the movement keys and still left-hand. That is a change to a control the ticket did not mention, and the one thing here worth arguing with. test_keyboard_input spelled Q and E out and so reported a broken pivot when the pivot was fine. It reads KEY_PIVOT_CW/CCW off the controller now, and additionally asserts the pair is not bound to an ability key - the failure that would actually matter. #230 - the upkeep menu never added anything up. Two faults, and the second is invisible from the code. There was no TOTAL, so the bill had to be done in the player's head. And the rows were one Label padded with %-16s, which aligns only in a monospace font - the theme's is CarroisGothicSC, which is proportional - with autowrap on top, so a long row folded and the table lost its shape. A real GridContainer now: header, a row per paying berth, and a TOTAL. The headline carries both figures, because they answer different questions - the per-charge bill is what the player watches happen, the per-minute rate is what they plan against. Amounts are listed in the game's resource order rather than the dictionary's. The screenshot showed "2 ore 1 energy" on one row above "2 gas 2 ore" on the next, which defeats the point of a column. The sheet is rebuilt only when the numbers change. BuildController refreshes this menu every frame while it is open, and rebuilding a dozen Labels per frame for figures that move every 25 seconds is what #6 and #10 are about. The test refreshes twice and asserts the same cell objects come back. Every figure still comes from fleet_upkeep_breakdown(), the calculation the charging loop itself runs - the new total sums those same per-berth costs rather than recomputing them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- controllers/GameController.gd | 7 +- controllers/MouseCombatController.gd | 25 +++++ tests/test_ability_keys.gd | 121 +++++++++++++++++++++ tests/test_fleet_upkeep_sheet.gd | 150 +++++++++++++++++++++++++++ tests/test_keyboard_input.gd | 27 +++-- ui/menus/FleetUpkeepMenu.gd | 123 ++++++++++++++++++---- ui/menus/FleetUpkeepMenu.tscn | 14 ++- 7 files changed, 433 insertions(+), 34 deletions(-) create mode 100644 tests/test_ability_keys.gd create mode 100644 tests/test_fleet_upkeep_sheet.gd diff --git a/controllers/GameController.gd b/controllers/GameController.gd index 168774c..45c4b0e 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -92,8 +92,11 @@ const KEY_DIRECTIONS: Dictionary = { KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT, KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT, } -const KEY_PIVOT_CW: int = KEY_E -const KEY_PIVOT_CCW: int = KEY_Q +# Z and C rather than Q and E: #231 gives Q, E and space to the abilities, so +# that each one is triggered directly instead of being selected first. Pivot +# stays under the same hand, one row down from the movement keys. +const KEY_PIVOT_CW: int = KEY_C +const KEY_PIVOT_CCW: int = KEY_Z const WHEEL_ZOOM_STEP: float = 1.12 diff --git a/controllers/MouseCombatController.gd b/controllers/MouseCombatController.gd index 9777eb3..336d1a0 100644 --- a/controllers/MouseCombatController.gd +++ b/controllers/MouseCombatController.gd @@ -25,6 +25,18 @@ extends Node2D # as "nonexistent function" on a node that looks fine in the scene. const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController", "DashController"] +# Forgejo #231. Selecting a weapon and then clicking is two acts for one +# shot, and on a keyboard the selection is invisible until you look at the +# buttons - which is what made the abilities "hard to use". Each one now has +# a key that fires it outright, at wherever the mouse already is. +# +# Indices into ABILITY_ORDER above, so these stay in step with it. +const ABILITY_KEYS: Dictionary = { + KEY_Q: 0, + KEY_E: 1, + KEY_SPACE: 2, +} + @onready var fleet: Node2D = get_node("../Fleet") @onready var camera: Camera2D = get_node("../Camera2D") @onready var fire_stick_controller: Node = get_node_or_null("../FireStickController") @@ -97,6 +109,19 @@ func _unhandled_input(event: InputEvent) -> void: aim_world = get_global_mouse_position() return if event is InputEventKey and event.pressed and not event.echo: + # Fired where the mouse already points, so pressing the key is the + # whole action. fire_at() carries every guard the click path uses - + # no hull ready, cannot afford it, a panel covering the button. + if ABILITY_KEYS.has(event.keycode): + var slot: int = int(ABILITY_KEYS[event.keycode]) + if slot < abilities.size(): + aim_world = get_global_mouse_position() + abilities[slot].fire_at(aim_world) + get_viewport().set_input_as_handled() + return + # The number keys still choose what a CLICK does. They are not what + # #231 is about, and removing them would take the click path's + # abilities with them. var index: int = event.keycode - KEY_1 if index >= 0 and index < weapon_count() and select_weapon(index): get_viewport().set_input_as_handled() diff --git a/tests/test_ability_keys.gd b/tests/test_ability_keys.gd new file mode 100644 index 0000000..1dabd24 --- /dev/null +++ b/tests/test_ability_keys.gd @@ -0,0 +1,121 @@ +extends SceneTree + +# Forgejo #231: on a computer each ability has a key that fires it outright. +# +# Selecting a weapon with a number key and then clicking is two acts for one +# shot, and the selection is invisible until you look at the buttons - which +# is what made the abilities hard to use with a keyboard. Q, E and space now +# fire the gas bomb, the laser and the dash where the mouse already points. +# +# Covers: +# A. the three keys map to the three abilities, in the documented order +# B. pressing one fires THAT ability and no other +# C. it fires at the mouse, so pointing is the aim +# D. Q and E no longer pivot, which is what they used to do +# E. the number keys still choose what a CLICK does - #231 replaces the +# selection as a way to FIRE, not the click path itself +# F. touch is untouched: on a phone these keys do nothing + +var scene: Node +var mouse: Node +var controller: Node +var game_state: Node +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.reset_new_game() + game_state.intro_active = false + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + mouse = scene.get_node("MouseCombatController") + # GameController is the ROOT of Main.tscn, not a child of it - the same + # script roots the planet surface too. + controller = scene + + +func _key(code: int) -> InputEventKey: + var event := InputEventKey.new() + event.keycode = code + event.pressed = true + return event + + +func _process(delta: float) -> bool: + elapsed += delta + if elapsed < 0.4: + return false + + # --- A. the mapping is the documented one ---------------------------- + if mouse.ABILITY_ORDER.size() != 3: + return _fail("expected 3 abilities, found %d" % mouse.ABILITY_ORDER.size()) + var expected: Dictionary = {KEY_Q: "GasBombController", KEY_E: "LaserController", + KEY_SPACE: "DashController"} + for code in expected: + if not mouse.ABILITY_KEYS.has(code): + return _fail("key %d is not bound to an ability" % code) + var slot: int = int(mouse.ABILITY_KEYS[code]) + if String(mouse.ABILITY_ORDER[slot]) != String(expected[code]): + return _fail("key %d fires %s, expected %s" \ + % [code, mouse.ABILITY_ORDER[slot], expected[code]]) + print("Q, E and space map to the bomb, the laser and the dash") + + # --- D. and they no longer pivot -------------------------------------- + if controller.KEY_PIVOT_CW in [KEY_Q, KEY_E, KEY_SPACE] \ + or controller.KEY_PIVOT_CCW in [KEY_Q, KEY_E, KEY_SPACE]: + return _fail("pivot still uses an ability key (cw=%d ccw=%d)" \ + % [controller.KEY_PIVOT_CW, controller.KEY_PIVOT_CCW]) + print("pivot moved to %d/%d, clear of the ability keys" \ + % [controller.KEY_PIVOT_CCW, controller.KEY_PIVOT_CW]) + + # --- B/C. the KEY itself reaches that ability, aimed at the mouse ----- + # Driven through _unhandled_input rather than by calling fire_at(), or + # this would prove the abilities work and nothing about the binding. + # + # Observed through aim_world: the ability branch overwrites it with the + # live cursor before firing, so a value that survives means the branch + # never ran. The abilities themselves are locked in a fresh game, so + # fire_at() correctly refuses - the wiring is what is under test here. + mouse.pointer_mode = 1 # PointerMode.ON - a headless display reports no mouse + var sentinel := Vector2(98765.0, -4321.0) + for code in [KEY_Q, KEY_E, KEY_SPACE]: + mouse.aim_world = sentinel + mouse._unhandled_input(_key(code)) + if mouse.aim_world == sentinel: + var slot: int = int(mouse.ABILITY_KEYS[code]) + return _fail("key %d never reached %s" % [code, mouse.ABILITY_ORDER[slot]]) + print("Q, E and space each reach their ability through the input path") + + # A key bound to nothing must fall through to the number-key branch + # rather than being swallowed by the ability one. + mouse.aim_world = sentinel + mouse._unhandled_input(_key(KEY_K)) + if mouse.aim_world != sentinel: + return _fail("an unbound key was treated as an ability trigger") + print("an unbound key is left alone") + + # --- E. the number keys still select for the click path --------------- + if not mouse.select_weapon(1): + return _fail("the number keys no longer select a weapon for a click") + if mouse.selected_weapon != 1: + return _fail("selecting weapon 1 left selected_weapon at %d" % mouse.selected_weapon) + print("the number keys still choose what a click fires") + + # --- F. and none of this reaches a phone ------------------------------ + mouse.pointer_mode = 2 # PointerMode.OFF + if mouse.is_enabled(): + return _fail("mouse combat is live with the pointer mode off") + print("with a touch pointer the keys are inert") + + print("TEST_ABILITY_KEYS: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_ABILITY_KEYS: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_fleet_upkeep_sheet.gd b/tests/test_fleet_upkeep_sheet.gd new file mode 100644 index 0000000..a1df891 --- /dev/null +++ b/tests/test_fleet_upkeep_sheet.gd @@ -0,0 +1,150 @@ +extends SceneTree + +# Forgejo #230: the upkeep menu shows what the fleet costs IN TOTAL, laid out +# as a table. +# +# It listed each berth and never added them up, so the bill had to be done in +# the player's head. The rows were also one Label padded with %-16s, which +# lines up only in a monospace font - the theme's is CarroisGothicSC, which +# is proportional, so the "columns" never actually aligned. Worse, that Label +# had autowrap on, so a long row folded and the table lost its shape. +# +# Covers: +# A. a row per paying berth, in a real grid +# B. a TOTAL row, and it equals the sum of the rows +# C. the headline states both the per-charge bill and the per-minute rate +# D. an unpaid berth is marked +# E. with nothing to charge, the table gives way to an explanation +# F. the sheet is not rebuilt on every frame - the menu refreshes at frame +# rate and these numbers move every 25 seconds + +var game_state: Node +var scene: Node +var menu: Control +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_new_game() + game_state.reset_quests() + game_state.intro_active = false + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + menu = scene.get_node("UI/FleetUpkeepMenu") + + +func _rows() -> Array: + # Grid cells come out in reading order; COLUMNS wide makes one row. + var width: int = menu.sheet.columns + var cells: Array = [] + for child in menu.sheet.get_children(): + cells.append(String((child as Label).text)) + var rows: Array = [] + for i in range(0, cells.size(), width): + rows.append(cells.slice(i, i + width)) + return rows + + +func _process(delta: float) -> bool: + elapsed += delta + if elapsed < 0.4: + return false + + # --- E. nothing to charge ------------------------------------------- + menu.open_menu() + menu.refresh() + if not menu.empty_label.visible or menu.sheet.visible: + return _fail("with no berths the table is shown instead of an explanation") + if not menu.total_label.text.contains("NOTHING"): + return _fail("with no berths the headline reads '%s'" % menu.total_label.text) + print("with nothing to charge: '%s'" % menu.total_label.text) + + # Two offices, two crewed berths - the second costs more than the first. + game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2.ZERO) + game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(600.0, 0.0)) + game_state.fleet_vessel_types = PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR, + GameState.VESSEL_INTERCEPTOR]) + menu.refresh() + + var breakdown: Dictionary = game_state.fleet_upkeep_breakdown() + if breakdown.berths.size() < 2: + return _fail("only %d berth(s) are being charged - that proves nothing about a total" \ + % breakdown.berths.size()) + + # --- A. a row per berth, plus header and total ------------------------ + var rows: Array = _rows() + if not menu.sheet.visible or menu.empty_label.visible: + return _fail("the table is hidden while berths are being charged") + if rows.size() != breakdown.berths.size() + 2: + return _fail("%d rows for %d berths - expected a header and a total too" \ + % [rows.size(), breakdown.berths.size()]) + if String(rows[0][0]) != "BERTH": + return _fail("the first row is not a header: %s" % str(rows[0])) + print("%d berths -> %d rows (header, %d berths, total)" \ + % [breakdown.berths.size(), rows.size(), breakdown.berths.size()]) + + # --- B. and the total is the sum of the rows -------------------------- + var total_row: Array = rows[rows.size() - 1] + if String(total_row[0]) != "TOTAL": + return _fail("the last row is '%s', not a total" % total_row[0]) + var summed: int = 0 + for berth in breakdown.berths: + for resource_type in berth.costs: + summed += int(berth.costs[resource_type]) + var shown: int = 0 + for word in String(total_row[2]).split(" "): + if word.is_valid_int(): + shown += int(word) + if shown != summed: + return _fail("the total row reads '%s' (%d), but the berths add to %d" \ + % [total_row[2], shown, summed]) + if summed <= 0: + return _fail("the berths cost nothing, so a total proves nothing") + print("total row '%s' matches the %d the berths add to" % [total_row[2], summed]) + + # --- C. the headline carries both figures ----------------------------- + var headline: String = menu.total_label.text + if not headline.contains("PER MINUTE") or not headline.contains("EVERY"): + return _fail("the headline does not state both the bill and the rate: '%s'" % headline) + print("headline: %s" % headline) + + # --- F. and it is not rebuilt every frame ----------------------------- + var first: Array = menu.sheet.get_children() + menu.refresh() + menu.refresh() + var again: Array = menu.sheet.get_children() + if first.size() != again.size(): + return _fail("the row count changed across a refresh with no change behind it") + for i in range(first.size()): + if first[i] != again[i]: + return _fail("refreshing rebuilt the table though nothing changed") + print("refreshing twice reuses the same %d cells" % again.size()) + + # --- D. an unpaid berth is called out --------------------------------- + for building in game_state.buildings: + if String(building.type) == GameState.BUILDING_FLEET_OFFICE: + building["starved"] = true + menu.refresh() + var marked: bool = false + for row in _rows(): + if String(row[4]) == "UNPAID": + marked = true + if not marked: + return _fail("a starved berth is not marked unpaid") + print("a starved berth is marked UNPAID") + + print("TEST_FLEET_UPKEEP_SHEET: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_FLEET_UPKEEP_SHEET: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_keyboard_input.gd b/tests/test_keyboard_input.gd index 271cd78..d633092 100644 --- a/tests/test_keyboard_input.gd +++ b/tests/test_keyboard_input.gd @@ -4,7 +4,7 @@ extends SceneTree # godot --headless -s res://tests/test_keyboard_input.gd # Desktop/web controls. The phone build is driven entirely by touch, but the # web build is played with a keyboard and mouse, so WASD (and the arrows) -# must steer the fleet, Q/E must pivot the formation, and releasing the keys +# must steer the fleet, the pivot pair must turn the formation, and releasing # must stop - the stick has a centred-again signal to hook, the keyboard has # nothing equivalent, so a missed release would leave the fleet flying off # forever. @@ -127,21 +127,32 @@ func _process(delta: float) -> bool: quit(1) return true - # --- Q/E pivot ------------------------------------------------ + # --- the pivot pair ------------------------------------------- + # Read off the controller's own constants rather than naming the + # keys again here. #231 moved this pair from Q/E to Z/C because + # the abilities took Q, E and space, and a test that spelled the + # old keys out reported a broken pivot when the pivot was fine. var game_state: Node = root.get_node("GameState") + var cw_key: int = controller.KEY_PIVOT_CW + var ccw_key: int = controller.KEY_PIVOT_CCW + if cw_key in [KEY_Q, KEY_E, KEY_SPACE] or ccw_key in [KEY_Q, KEY_E, KEY_SPACE]: + print("TEST_KEYBOARD_INPUT: FAIL (pivot is bound to an ability key)") + quit(1) + return true var before: float = game_state.formation[1].angle() - _key(KEY_E, true) - _key(KEY_E, false) + _key(cw_key, true) + _key(cw_key, false) var after_cw: float = game_state.formation[1].angle() - _key(KEY_Q, true) - _key(KEY_Q, false) + _key(ccw_key, true) + _key(ccw_key, false) var after_ccw: float = game_state.formation[1].angle() var cw_turn: float = angle_difference(before, after_cw) var ccw_turn: float = angle_difference(after_cw, after_ccw) - print("E turned %.3f rad, Q turned %.3f rad" % [cw_turn, ccw_turn]) + print("key %d turned %.3f rad, key %d turned %.3f rad" \ + % [cw_key, cw_turn, ccw_key, ccw_turn]) if absf(cw_turn) < 0.01 or absf(ccw_turn) < 0.01 or signf(cw_turn) == signf(ccw_turn): - print("TEST_KEYBOARD_INPUT: FAIL (Q/E did not pivot in opposite directions)") + print("TEST_KEYBOARD_INPUT: FAIL (the pivot pair did not turn in opposite directions)") quit(1) return true diff --git a/ui/menus/FleetUpkeepMenu.gd b/ui/menus/FleetUpkeepMenu.gd index c662d52..5b21717 100644 --- a/ui/menus/FleetUpkeepMenu.gd +++ b/ui/menus/FleetUpkeepMenu.gd @@ -14,10 +14,22 @@ extends "res://ui/menus/ModalMenu.gd" # of "the game is lying to me" the ticket is about. @onready var total_label: Label = $TotalLabel -@onready var detail_label: Label = $DetailLabel +@onready var sheet: GridContainer = $Sheet +@onready var empty_label: Label = $EmptyLabel @onready var formula_label: Label = $FormulaLabel +const HEADER_COLOR: Color = Color(0.55, 0.78, 0.86) +const CELL_COLOR: Color = Color(0.72, 0.88, 0.96) +const TOTAL_COLOR: Color = Color(1.0, 0.85, 0.35) +const UNPAID_COLOR: Color = Color(1.0, 0.45, 0.4) +const COLUMNS: Array = ["BERTH", "VESSEL", "PER CHARGE", "RATE", ""] + var building_id: int = 0 +# What the sheet was last built from. The menu is refreshed every frame while +# it is open (see BuildController), and rebuilding a dozen Labels per frame to +# show numbers that change every 25 seconds is the thing issues #6 and #10 +# are about. +var _built_from: String = "" func open_for(next_building_id: int) -> void: @@ -38,34 +50,99 @@ func refresh() -> void: var berths: Array = breakdown.berths var per_minute: Dictionary = breakdown.per_minute + # The bill, both ways round: what leaves the stores at each charge, and + # what that adds up to over a minute. The per-charge figure is the one + # the player watches happen; the rate is the one they plan against. + var totals: Dictionary = {} + for berth in berths: + for resource_type in berth.costs: + totals[resource_type] = int(totals.get(resource_type, 0)) \ + + int(berth.costs[resource_type]) if per_minute.is_empty(): total_label.text = "NOTHING TO KEEP" else: - var parts: PackedStringArray = PackedStringArray() - for resource_type in per_minute: - parts.append("%.1f %s" % [float(per_minute[resource_type]), - GameState.resource_name(String(resource_type)).to_lower()]) - total_label.text = "%s PER MINUTE" % " · ".join(parts) + total_label.text = "%s EVERY %dS · %s PER MINUTE" % [ + _amounts(totals), int(breakdown.charge_seconds), _rates(per_minute)] - # One line per paying berth: which hull, what it is charged, and the - # multiplier that got it there. An empty berth is charged nothing and is - # left out rather than listed at zero. - var lines: PackedStringArray = PackedStringArray() - for berth in berths: - var costs: PackedStringArray = PackedStringArray() - for resource_type in berth.costs: - costs.append("%d %s" % [int(berth.costs[resource_type]), - GameState.resource_name(String(resource_type)).to_lower()]) - lines.append("BERTH %d %-16s %s (x%.2f)%s" % [ - int(berth.slot), GameState.vessel_name(String(berth.vessel_type)), - " ".join(costs), float(berth.scale), - " — UNPAID" if bool(berth.starved) else "", - ]) - detail_label.text = "\n".join(lines) if not lines.is_empty() \ - else "No berth is being charged. Build a Fleet Office and put a vessel in it." + var signature: String = "%s|%s" % [str(berths), str(totals)] + if signature != _built_from: + _built_from = signature + _build_sheet(berths, totals) - formula_label.text = ("Each berth is charged every %ds: the vessel's build price " + empty_label.visible = berths.is_empty() + sheet.visible = not berths.is_empty() + empty_label.text = "No berth is being charged. Build a Fleet Office and put a vessel in it." + + formula_label.text = ("Each berth is charged every %ds: the vessel\'s build price " + "divided by %d, then multiplied by %.2f for every berth past the first " + "paid one. The first vessel is free.") % [ int(breakdown.charge_seconds), int(breakdown.divisor), float(breakdown.growth)] + + +# One row per paying berth, plus a totals row - a berth standing empty is +# charged nothing and is left out rather than listed at zero. +func _build_sheet(berths: Array, totals: Dictionary) -> void: + for child in sheet.get_children(): + child.free() + if berths.is_empty(): + return + for column in COLUMNS: + _cell(String(column), HEADER_COLOR) + for berth in berths: + var starved: bool = bool(berth.starved) + var row_color: Color = UNPAID_COLOR if starved else CELL_COLOR + _cell(str(int(berth.slot)), row_color) + _cell(GameState.vessel_name(String(berth.vessel_type)), row_color) + _cell(_amounts(berth.costs), row_color) + _cell("x%.2f" % float(berth.scale), row_color) + _cell("UNPAID" if starved else "", UNPAID_COLOR) + # The totals row is what #230 asked for: the per-berth lines never added + # up to anything on screen, so the bill had to be done in the head. + _cell("TOTAL", TOTAL_COLOR) + _cell("", TOTAL_COLOR) + _cell(_amounts(totals), TOTAL_COLOR) + _cell("", TOTAL_COLOR) + _cell("", TOTAL_COLOR) + + +func _cell(text: String, colour: Color) -> void: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 28) + label.add_theme_color_override("font_color", colour) + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + sheet.add_child(label) + + +# In the game's own resource order, not the dictionary's. Iterating the costs +# directly put "2 ore 1 energy" on one row and "2 gas 2 ore" on the next, +# which is most of what a column is for - you cannot read down a table whose +# entries are in a different order on every line. +func _ordered(costs: Dictionary) -> Array: + var out: Array = [] + for resource_type in GameState.resource_types(): + if costs.has(resource_type): + out.append(resource_type) + # Anything the catalog does not know about still gets shown rather than + # silently dropped. + for resource_type in costs: + if not out.has(resource_type): + out.append(resource_type) + return out + + +func _amounts(costs: Dictionary) -> String: + var parts: PackedStringArray = PackedStringArray() + for resource_type in _ordered(costs): + parts.append("%d %s" % [int(costs[resource_type]), + GameState.resource_name(String(resource_type)).to_lower()]) + return " ".join(parts) if not parts.is_empty() else "-" + + +func _rates(per_minute: Dictionary) -> String: + var parts: PackedStringArray = PackedStringArray() + for resource_type in _ordered(per_minute): + parts.append("%.1f %s" % [float(per_minute[resource_type]), + GameState.resource_name(String(resource_type)).to_lower()]) + return " ".join(parts) diff --git a/ui/menus/FleetUpkeepMenu.tscn b/ui/menus/FleetUpkeepMenu.tscn index ad4d649..dd5c669 100644 --- a/ui/menus/FleetUpkeepMenu.tscn +++ b/ui/menus/FleetUpkeepMenu.tscn @@ -50,16 +50,28 @@ theme_override_colors/font_color = Color(1, 0.85, 0.35, 1) text = "" horizontal_alignment = 1 -[node name="DetailLabel" type="Label" parent="."] +[node name="Sheet" type="GridContainer" parent="."] mouse_filter = 2 anchor_right = 1.0 offset_left = 32.0 offset_top = 250.0 offset_right = -32.0 offset_bottom = 860.0 +columns = 5 +theme_override_constants/h_separation = 24 +theme_override_constants/v_separation = 10 + +[node name="EmptyLabel" type="Label" parent="."] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 32.0 +offset_top = 250.0 +offset_right = -32.0 +offset_bottom = 400.0 theme_override_font_sizes/font_size = 30 theme_override_colors/font_color = Color(0.72, 0.88, 0.96, 1) text = "" +horizontal_alignment = 1 autowrap_mode = 2 [node name="FormulaLabel" type="Label" parent="."]