diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index f9e0f44..9f7d52a 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1357,7 +1357,7 @@ text = "Gig XP: Loading…" [node name="CraftRecipePanel" type="Control" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000011] -custom_minimum_size = Vector2(656, 320) +custom_minimum_size = Vector2(656, 360) layout_mode = 2 size_flags_horizontal = 3 script = ExtResource("19_craft_panel") @@ -1369,6 +1369,9 @@ anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 +horizontal_scroll_mode = 0 +vertical_scroll_mode = 1 +follow_focus = true [node name="VBoxContainer" type="VBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection/Body/CraftRecipePanel/ScrollContainer" unique_id=9000013] layout_mode = 2 diff --git a/client/scripts/craft_recipe_panel.gd b/client/scripts/craft_recipe_panel.gd index 0d10dab..b186020 100644 --- a/client/scripts/craft_recipe_panel.gd +++ b/client/scripts/craft_recipe_panel.gd @@ -5,13 +5,22 @@ extends Control signal craft_requested(recipe_id: String) const PrototypeHudTheme := preload("res://scripts/prototype_hud_theme.gd") +const HudThemeConstants := preload("res://scripts/prototype_hud_theme_constants.gd") var _item_defs_client: Node = null var _craft_buttons: Array[Button] = [] +@onready var _scroll: ScrollContainer = $ScrollContainer @onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer +func _ready() -> void: + if is_instance_valid(_scroll): + _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + resized.connect(_sync_vbox_width) + + func setup(item_defs_client: Node) -> void: _item_defs_client = item_defs_client @@ -29,21 +38,26 @@ func populate(recipes: Array) -> void: var recipe_id: String = str(recipe.get("id", "")) if recipe_id.is_empty(): continue - var row := HBoxContainer.new() + var row := VBoxContainer.new() row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 4) var info := Label.new() info.size_flags_horizontal = Control.SIZE_EXPAND_FILL - info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART info.text = _format_recipe_line(recipe) - PrototypeHudTheme.apply_to_label(info, false) + _apply_recipe_row_label(info) row.add_child(info) + var btn_row := HBoxContainer.new() + btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + btn_row.alignment = BoxContainer.ALIGNMENT_END var craft_btn := Button.new() craft_btn.text = "Craft" PrototypeHudTheme.apply_to_button(craft_btn, false) craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id)) - row.add_child(craft_btn) + btn_row.add_child(craft_btn) + row.add_child(btn_row) _vbox.add_child(row) _craft_buttons.append(craft_btn) + call_deferred("_reset_scroll_after_populate") func set_craft_busy(busy: bool) -> void: @@ -85,3 +99,24 @@ func _item_display_name(item_id: String) -> String: func _on_craft_pressed(recipe_id: String) -> void: craft_requested.emit(recipe_id) + + +func _apply_recipe_row_label(label: Label) -> void: + label.add_theme_font_size_override("font_size", HudThemeConstants.FONT_SIZE_SECONDARY) + label.add_theme_constant_override("outline_size", HudThemeConstants.OUTLINE_SIZE) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + + +func _sync_vbox_width() -> void: + if not is_instance_valid(_scroll) or not is_instance_valid(_vbox): + return + var width: float = _scroll.size.x + if width <= 0.0: + return + _vbox.custom_minimum_size.x = width + + +func _reset_scroll_after_populate() -> void: + _sync_vbox_width() + if is_instance_valid(_scroll): + _scroll.scroll_vertical = 0 diff --git a/client/test/craft_recipe_panel_test.gd b/client/test/craft_recipe_panel_test.gd new file mode 100644 index 0000000..6469219 --- /dev/null +++ b/client/test/craft_recipe_panel_test.gd @@ -0,0 +1,81 @@ +extends GdUnitTestSuite + +## NEO-122 follow-up: craft recipe panel layout + scroll reset on populate. + +const CraftRecipePanel := preload("res://scripts/craft_recipe_panel.gd") + + +static func _sample_recipes() -> Array: + return [ + { + "id": "refine_scrap_standard", + "displayName": "Refine Scrap (Standard)", + "inputs": [{"itemId": "scrap_metal_bulk", "quantity": 5}], + }, + { + "id": "refine_scrap_efficient", + "displayName": "Refine Scrap (Efficient)", + "inputs": [{"itemId": "scrap_metal_bulk", "quantity": 10}], + }, + ] + + +func _build_panel() -> Control: + var panel: Control = CraftRecipePanel.new() + panel.custom_minimum_size = Vector2(656, 360) + var scroll := ScrollContainer.new() + scroll.name = "ScrollContainer" + scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var vbox := VBoxContainer.new() + vbox.name = "VBoxContainer" + scroll.add_child(vbox) + panel.add_child(scroll) + auto_free(panel) + add_child(panel) + await get_tree().process_frame + return panel + + +func test_populate_builds_one_row_per_recipe() -> void: + # Arrange + var panel := await _build_panel() + # Act + panel.call("populate", _sample_recipes()) + await get_tree().process_frame + # Assert + var vbox: VBoxContainer = panel.get_node("ScrollContainer/VBoxContainer") + assert_that(vbox.get_child_count()).is_equal(2) + + +func test_populate_resets_scroll_to_top() -> void: + # Arrange + var panel := await _build_panel() + var scroll: ScrollContainer = panel.get_node("ScrollContainer") + panel.call("populate", _sample_recipes()) + await get_tree().process_frame + scroll.scroll_vertical = 999 + # Act + panel.call("populate", _sample_recipes()) + await get_tree().process_frame + # Assert + assert_that(scroll.scroll_vertical).is_equal(0) + + +func test_populate_emits_craft_requested_for_row() -> void: + # Arrange + var panel := await _build_panel() + var captured: Array = [] + panel.connect( + "craft_requested", func(recipe_id: String) -> void: captured.append(recipe_id) + ) + panel.call("populate", _sample_recipes()) + await get_tree().process_frame + var vbox: VBoxContainer = panel.get_node("ScrollContainer/VBoxContainer") + var first_row: VBoxContainer = vbox.get_child(0) as VBoxContainer + var btn_row: HBoxContainer = first_row.get_child(1) as HBoxContainer + var craft_btn: Button = btn_row.get_child(0) as Button + # Act + craft_btn.pressed.emit() + # Assert + assert_that(captured.size()).is_equal(1) + assert_that(str(captured[0])).is_equal("refine_scrap_standard") diff --git a/client/test/craft_recipe_panel_test.gd.uid b/client/test/craft_recipe_panel_test.gd.uid new file mode 100644 index 0000000..f0a76e0 --- /dev/null +++ b/client/test/craft_recipe_panel_test.gd.uid @@ -0,0 +1 @@ +uid://bneo122craftpanel1 diff --git a/docs/manual-qa/NEO-122.md b/docs/manual-qa/NEO-122.md index 0d8965d..0eb29af 100644 --- a/docs/manual-qa/NEO-122.md +++ b/docs/manual-qa/NEO-122.md @@ -34,8 +34,9 @@ 4. Gather scrap until gather quest completes (resource node **R**). Verify counter updates on **`QuestProgressLabel`** after each successful gather (may lag one GET). 5. Press **Shift+Q** before gather is complete — verify **`Quest accept: denied — prerequisite_incomplete`** for refine (or **`no eligible quest`** if refine is not yet eligible). 6. After gather completes, press **Shift+Q**. Verify refine quest becomes **`active`**. -7. Craft **`refine_scrap_standard`** with Economy HUD open. Verify refine quest **`completed`** after successful craft. -8. Press **Q** again on already-completed gather. Verify deny feedback on **`QuestAcceptFeedbackLabel`** without Bruno. +7. Open **Economy HUD** recipe panel — **Refine Scrap (Standard)** is the **first** row (scroll starts at top). Craft when **≥5** scrap in bag. +8. Craft **`refine_scrap_standard`** with Economy HUD open. Verify refine quest **`completed`** after successful craft. +9. Press **Q** again on already-completed gather. Verify deny feedback on **`QuestAcceptFeedbackLabel`** without Bruno. 9. Optional: stop server while Godot running, press **Q** — verify **`Quests: error — …`** or **`Quest accept: failed — …`** visible on HUD. 10. Optional: block **`GET /game/world/quest-definitions`** only while progress GET succeeds — verify **`definitions error — …`** under **`QuestProgressLabel`** with raw quest ids as fallback.