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="."]