diff --git a/BuildController.gd b/BuildController.gd index ab878fc..7414975 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -28,6 +28,18 @@ extends Node2D @onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") @onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton") @onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton") +@onready var build_menu_entries: Array = [ + [GameState.BUILDING_MOTHERSHIP, build_mothership_button, + get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")], + [GameState.BUILDING_SHIP_CRAFTER, build_crafter_button, + get_node("../UI/BuildMenu/CrafterLabel"), get_node("../UI/BuildMenu/CrafterCost")], + [GameState.BUILDING_COMMAND_CENTER, build_command_button, + get_node("../UI/BuildMenu/CommandLabel"), get_node("../UI/BuildMenu/CommandCost")], + [GameState.BUILDING_EXTRACTOR, build_extractor_button, + get_node("../UI/BuildMenu/ExtractorLabel"), get_node("../UI/BuildMenu/ExtractorCost")], + [GameState.BUILDING_TRANSPORT_HUB, build_hub_button, + get_node("../UI/BuildMenu/TransportHubLabel"), get_node("../UI/BuildMenu/TransportHubCost")], +] @onready var planet_field: Node2D = get_node("../PlanetField") @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @@ -185,6 +197,7 @@ func _process(delta: float) -> void: # a planet lights the option up without reopening it. build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) + build_extractor_button.queue_redraw() if craft_menu.visible: # Live, so a delivery arriving while the menu is open visibly works @@ -266,14 +279,58 @@ func _planet_under_fleet() -> Node2D: return planet_field.get_planet_fleet_is_on() +# Grid geometry, shared with the layout below. Rows of two, and the menu is +# rebuilt from whatever is currently researched (#21) rather than being a +# fixed arrangement with holes punched in it. +const MENU_ROW_TOPS: Array = [-300.0, -10.0, 280.0] +const MENU_COL_X: float = 140.0 +const MENU_BUTTON_HALF: float = 64.0 +const MENU_BUTTON_H: float = 128.0 +const MENU_LABEL_HALF: float = 150.0 +const MENU_LABEL_H: float = 66.0 +const MENU_COST_HALF: float = 60.0 +const MENU_COST_H: float = 52.0 + + +func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void: + node.offset_left = cx - half + node.offset_right = cx + half + node.offset_top = top + node.offset_bottom = top + height + + +func _layout_build_menu() -> void: + # Structures the player has not researched are hidden outright (#21), and + # what remains is repacked so there are no gaps where they used to be. + # This reverses the earlier decision to always list everything greyed + # out; an option you cannot explain is apparently worse than one you + # cannot see yet. + var shown: Array = [] + for entry in build_menu_entries: + var visible: bool = GameState.is_building_unlocked(entry[0]) + entry[1].visible = visible + entry[2].visible = visible + entry[3].visible = visible + if visible: + shown.append(entry) + + for i in range(shown.size()): + var row: int = i / 2 + # Centred when it is the only one on its row, rather than hanging in + # the left column looking like something is missing beside it. + var alone: bool = (i == shown.size() - 1) and (i % 2 == 0) + var cx: float = 0.0 if alone else (-MENU_COL_X if i % 2 == 0 else MENU_COL_X) + var top: float = MENU_ROW_TOPS[mini(row, MENU_ROW_TOPS.size() - 1)] + _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) + _place_entry(shown[i][2], cx, top + 134.0, MENU_LABEL_HALF, MENU_LABEL_H) + _place_entry(shown[i][3], cx, top + 204.0, MENU_COST_HALF, MENU_COST_H) + + func _refresh_build_status() -> void: - # Every option is always listed, greyed when it cannot be used - showing - # them only sometimes would leave the player wondering whether the game - # has them at all. Greyed buttons still emit `pressed`, so each one can - # explain itself in the info popup. - build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) - build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER) - build_hub_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB) + _layout_build_menu() + # Researched but not usable right here is still shown, greyed: an + # extractor needs a planet under the fleet, which is a "not now" rather + # than a "not yet". build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) diff --git a/Fleet.gd b/Fleet.gd index 6abccd5..de7871b 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -168,6 +168,14 @@ func stop() -> void: # enough that ships always lag and always report motion, which is # the only reason releasing the stick looked right. var motion: Vector2 = ship.velocity + ship.fleet_velocity + # HAND THE FLEET'S MOTION TO THE SHIP. Until now the anchor was + # carrying it along and its own `velocity` was ~0, so aiming it at a + # braking point ahead made it start from REST: the fleet stopped + # dead, then accelerated into its own stopping distance and halted a + # second time. That is the "pause, then it moves again" stutter. + # Giving it the velocity it was actually travelling at makes the + # stop one continuous decelerating coast. + ship.velocity = motion # Context that is no longer true: the fleet is not cruising any # more, so leaving it set would have Ship face a heading it is not # travelling on. diff --git a/tests/_build_menu_preview.gd b/tests/_build_menu_preview.gd index 5132bbf..7c410e9 100644 --- a/tests/_build_menu_preview.gd +++ b/tests/_build_menu_preview.gd @@ -14,7 +14,9 @@ func _ready() -> void: GameState.rocks = 500 # Everything researched, so nothing is greyed out and the whole menu is # legible at full strength. - GameState.unlocked = ["shipyards", "logistics", "freight", "coordination"] + # Partly researched, to show the menu repacking around what is hidden. + GameState.unlocked = ["shipyards", "logistics"] var main: Node = load("res://Main.tscn").instantiate() add_child(main) main.get_node("UI/BuildMenu").visible = true + main.get_node("BuildController")._refresh_build_status() diff --git a/tests/test_build_menu_hides_locked.gd b/tests/test_build_menu_hides_locked.gd new file mode 100644 index 0000000..50f215a --- /dev/null +++ b/tests/test_build_menu_hides_locked.gd @@ -0,0 +1,135 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_build_menu_hides_locked.gd +# Forgejo #21: a structure the player has not researched should not be in the +# build menu at all, rather than sitting there greyed out. +# +# The menu is a fixed grid in Main.tscn, so simply hiding entries would leave +# holes where they used to be - it is repacked from whatever is researched. +# +# Verifies: +# 1. with nothing researched only the mothership is offered (it is +# deliberately never gated - it is where research happens) +# 2. researching a structure adds it, and it is placed, not left at +# whatever offsets it happened to have in the scene file +# 3. no gaps: what is shown occupies consecutive grid slots +# 4. an entry alone on its row is centred, not hanging in the left column +# 5. researched-but-not-usable is still shown greyed (an extractor with no +# planet under the fleet is "not now", not "not yet") + +var game_state: Node +var scene: Node +var controller: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.rocks = 500 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + + +func _shown() -> Array: + var out: Array = [] + for entry in controller.build_menu_entries: + if entry[1].visible: + out.append(entry) + return out + + +func _slots() -> Array: + # (row, x) of every visible button, in menu order. + var out: Array = [] + for entry in _shown(): + var b: Control = entry[1] + out.append([b.offset_top, b.offset_left + (b.offset_right - b.offset_left) / 2.0]) + return out + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- 1. nothing researched ---------------------------------- + controller._refresh_build_status() + var shown: Array = _shown() + if shown.size() != 1 or shown[0][0] != GameState.BUILDING_MOTHERSHIP: + var names: Array = [] + for e in shown: + names.append(e[0]) + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (unresearched structures on offer: %s)" % [names]) + quit(1) + return true + # --- 4. and it is centred, being alone on its row ----------- + var only: Array = _slots()[0] + if absf(only[1]) > 0.01: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (the only entry sits at x=%.1f instead of centred)" % only[1]) + quit(1) + return true + print("nothing researched -> only the mothership, centred") + + # --- 2. research three more --------------------------------- + game_state.unlocked = ["shipyards", "logistics", "coordination"] + controller._refresh_build_status() + stage = 1 + return false + 1: + var shown2: Array = _shown() + if shown2.size() != 4: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (researched 3, expected 4 entries, got %d)" % shown2.size()) + quit(1) + return true + for e in shown2: + if e[0] == GameState.BUILDING_TRANSPORT_HUB: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (the unresearched transport hub is still listed)") + quit(1) + return true + + # --- 3. consecutive slots, no holes ------------------------- + var slots: Array = _slots() + var rows: Array = controller.MENU_ROW_TOPS + for i in range(slots.size()): + var want_row: float = rows[i / 2] + if absf(slots[i][0] - want_row) > 0.01: + print("slot %d at y=%.1f, expected row %d at y=%.1f" % [i, slots[i][0], i / 2, want_row]) + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (a gap was left where a hidden entry used to be)") + quit(1) + return true + var want_x: float = -controller.MENU_COL_X if i % 2 == 0 else controller.MENU_COL_X + 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) + return true + print("4 entries packed into consecutive slots, no gaps") + + # --- 5. researched but not usable is shown, greyed ---------- + var extractor_shown: bool = false + for e in shown2: + if e[0] == GameState.BUILDING_EXTRACTOR: + extractor_shown = true + if e[1].enabled: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (extractor offered as usable with no planet under the fleet)") + quit(1) + return true + if not extractor_shown: + print("TEST_BUILD_MENU_HIDES_LOCKED: FAIL (researched extractor hidden - 'not now' is not 'not yet')") + quit(1) + return true + print("researched extractor shown but greyed, with no planet in reach") + print("TEST_BUILD_MENU_HIDES_LOCKED: PASS") + quit(0) + return true + return false diff --git a/tests/test_joystick_control.gd b/tests/test_joystick_control.gd index 1a3e41a..17dcd26 100644 --- a/tests/test_joystick_control.gd +++ b/tests/test_joystick_control.gd @@ -21,6 +21,11 @@ var elapsed: float = 0.0 var start_centroid: Vector2 var moving_centroid: Vector2 var stop_check_frames: int = 0 +# Release-coast tracking (Forgejo #13). +var released: bool = false +var speed_at_release: float = 0.0 +var previous_speed: float = 0.0 +var accelerated_after_release: bool = false func _initialize() -> void: @@ -54,8 +59,27 @@ func _release(local: Vector2) -> void: joystick.handle_touch(ev) +func _max_ship_speed() -> float: + var top: float = 0.0 + for ship in fleet.ships: + top = maxf(top, ship.velocity.length()) + return top + + func _process(delta: float) -> bool: elapsed += delta + + # Releasing the stick must be ONE continuous decelerating coast. It used + # to stop dead and then accelerate into its own stopping distance, + # halting a second time - felt in game as a pause followed by a small + # extra lurch (#13). The ships were being aimed at a braking point ahead + # while their own velocity was ~0, because the anchor had been carrying + # them, so they started from rest. + if released: + var now: float = _max_ship_speed() + if now > previous_speed + 1.0: + accelerated_after_release = true + previous_speed = now match stage: 0: main_scene.get_node("AsteroidField").set_process(false) @@ -104,6 +128,9 @@ func _process(delta: float) -> bool: return true _release(joystick.size / 2.0) + released = true + speed_at_release = _max_ship_speed() + previous_speed = speed_at_release if joystick.direction != Vector2.ZERO: print("TEST_JOYSTICK_CONTROL: FAIL (direction not cleared on release)") quit(1) @@ -119,6 +146,20 @@ func _process(delta: float) -> bool: var speed: float = 0.0 for ship in fleet.ships: speed = max(speed, ship.velocity.length()) + # Carrying the fleet's cruise speed, not starting from a standstill. + if speed_at_release < fleet.cruise_speed * 0.5: + print("speed one frame after release: %.1f (cruising at %.1f)" % [ + speed_at_release, fleet.cruise_speed + ]) + print("TEST_JOYSTICK_CONTROL: FAIL (the fleet stopped dead on release instead of coasting)") + quit(1) + return true + if accelerated_after_release: + print("TEST_JOYSTICK_CONTROL: FAIL (the fleet sped up again after release - pause then lurch, #13)") + quit(1) + return true + print("release coast: %.1f -> 0 with no re-acceleration" % speed_at_release) + var stopped: bool = speed < 1.0 # Not merely "moved forward at all": the fleet cruises at # cruise_speed and brakes at Ship.acceleration, so the coast is diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd index 92e818f..ca9b7b0 100644 --- a/tests/test_skill_tree.gd +++ b/tests/test_skill_tree.gd @@ -8,8 +8,8 @@ extends SceneTree # # Verifies: # 1. from a fresh start only the mothership is buildable - the others are -# greyed, and pressing one explains which research opens it rather than -# silently failing +# not in the menu at all (#21 changed this from greyed-but-listed), and +# the gate still holds if the build is forced past the menu # 2. a locked option cannot be built even if the confirm is forced # 3. research costs rocks, needs its prerequisite, and unlocks its building # 4. the mothership's action button opens the tree (it used to have no @@ -69,16 +69,16 @@ func _process(delta: float) -> bool: return true controller._on_build_pressed() - var greyed: bool = not scene.get_node("UI/BuildMenu/CrafterButton").enabled + # Since #21 an unresearched structure is not listed at all, rather + # than listed and greyed. It is therefore unreachable from the + # menu - but the popup must still refuse if something calls + # _on_place_crafter() by another route. + var hidden: bool = not scene.get_node("UI/BuildMenu/CrafterButton").visible controller._on_place_crafter() - var explains: bool = popup.visible \ - and "research" in popup.get_node("Panel/MessageLabel").text.to_lower() var offered: bool = popup.get_node("Panel/BuildButton").visible - print("locked crafter: greyed=%s explains=%s confirm_offered=%s" % [ - greyed, explains, offered - ]) - if not (greyed and explains) or offered: - print("TEST_SKILL_TREE: FAIL (locked option did not explain itself)") + print("locked crafter: hidden_from_menu=%s confirm_offered=%s" % [hidden, offered]) + if not hidden or offered: + print("TEST_SKILL_TREE: FAIL (unresearched option still on offer)") quit(1) return true