NEO-122: Pin economy HUD to viewport so recipes stay visible.

Reparent EconomyHudSection out of HudRoot, clamp its height to the
viewport, put recipes first in a single BodyScroll, and update tests.
pull/161/head
VinPropane 2026-06-07 12:58:31 -04:00
parent e041c02959
commit d1fa08b8c4
8 changed files with 175 additions and 100 deletions

View File

@ -207,7 +207,7 @@ Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility
**Scripts:** `ability_cast_client.gd`, `combat_targets_client.gd`, `gig_progression_client.gd` — wired from `main.gd`. **Scripts:** `ability_cast_client.gd`, `combat_targets_client.gd`, `gig_progression_client.gd` — wired from `main.gd`.
**Economy HUD:** **`GigXpLabel`** lives under **`EconomyHudSection/Body/`** (hidden when **`Economy HUD`** collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries **`targetDefeated: true`** only — not on every hit. **Economy HUD:** **`GigXpLabel`** lives under **`EconomyHudSection/BodyScroll/Body/`** (hidden when **`Economy HUD`** collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries **`targetDefeated: true`** only — not on every hit.
**Preconditions:** **Server restart** before capstone run resets in-memory dummy HP and gig XP baseline. **Preconditions:** **Server restart** before capstone run resets in-memory dummy HP and gig XP baseline.
@ -267,7 +267,7 @@ Full capstone checklist: [`docs/manual-qa/NEO-111.md`](../docs/manual-qa/NEO-111
- **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http). - **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http).
- **`GET /game/world/item-definitions`** — cached on boot for **`displayName`** labels (NEO-53). - **`GET /game/world/item-definitions`** — cached on boot for **`displayName`** labels (NEO-53).
- **Scripts:** `scripts/inventory_client.gd`, `scripts/item_definitions_client.gd`; wired from `main.gd` on boot and on **`inventory_refresh`** (**I**). - **Scripts:** `scripts/inventory_client.gd`, `scripts/item_definitions_client.gd`; wired from `main.gd` on boot and on **`inventory_refresh`** (**I**).
- **HUD:** `UICanvas/EconomyHudSection/Body/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output. **`Economy HUD`** checkbox (NEO-75) collapses the economy block. - **HUD:** `UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output. **`Economy HUD`** checkbox (NEO-75) collapses the economy block.
- **Refresh:** press **I** (or call **`InventoryClient.request_sync_from_server()`** from `main.gd` helpers — entrypoint for NEO-73 gather/craft refresh). - **Refresh:** press **I** (or call **`InventoryClient.request_sync_from_server()`** from `main.gd` helpers — entrypoint for NEO-73 gather/craft refresh).
Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md). Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
@ -275,9 +275,9 @@ Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
## Gather feedback on interact (NEO-73) ## Gather feedback on interact (NEO-73)
- **`POST /game/players/{id}/interact`** on **`resource_node`** kinds — server gather engine (NEO-63); no separate gather HTTP on client. - **`POST /game/players/{id}/interact`** on **`resource_node`** kinds — server gather engine (NEO-63); no separate gather HTTP on client.
- **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`UICanvas/EconomyHudSection/Body/SkillProgressionLabel`**; boot hydrate + refresh after successful gather. - **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`UICanvas/EconomyHudSection/BodyScroll/Body/SkillProgressionLabel`**; boot hydrate + refresh after successful gather.
- **Scripts:** `scripts/skill_progression_client.gd`, `scripts/prototype_interactable_picker.gd`; **`interaction_request_client.gd`** emits **`interaction_result_received`**; wired from `main.gd`. - **Scripts:** `scripts/skill_progression_client.gd`, `scripts/prototype_interactable_picker.gd`; **`interaction_request_client.gd`** emits **`interaction_result_received`**; wired from `main.gd`.
- **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`UICanvas/EconomyHudSection/Body/InventoryLabel`** refreshes automatically on successful gather (no **I**). - **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel`** refreshes automatically on successful gather (no **I**).
- **R binding:** nearest in-range **`resource_node`** by horizontal distance to catalog anchors (same radius rule as glow preview). - **R binding:** nearest in-range **`resource_node`** by horizontal distance to catalog anchors (same radius rule as glow preview).
Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md). Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md).
@ -287,7 +287,7 @@ Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md).
- **`GET /game/world/recipe-definitions`** — eight prototype recipes on boot; scrollable panel inside **`EconomyHudSection`** (below **`SkillProgressionLabel`**). - **`GET /game/world/recipe-definitions`** — eight prototype recipes on boot; scrollable panel inside **`EconomyHudSection`** (below **`SkillProgressionLabel`**).
- **`POST /game/players/{id}/craft`** — JSON body `schemaVersion`, `recipeId`, `quantity` (default **1**); see [server README — Craft HTTP](../server/README.md). - **`POST /game/players/{id}/craft`** — JSON body `schemaVersion`, `recipeId`, `quantity` (default **1**); see [server README — Craft HTTP](../server/README.md).
- **Scripts:** `scripts/recipe_definitions_client.gd`, `scripts/craft_client.gd`, `scripts/craft_recipe_panel.gd`; wired from `main.gd`. - **Scripts:** `scripts/recipe_definitions_client.gd`, `scripts/craft_client.gd`, `scripts/craft_recipe_panel.gd`; wired from `main.gd`.
- **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`UICanvas/EconomyHudSection/Body/SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`UICanvas/EconomyHudSection/Body/InventoryLabel`** refreshes on craft success only (no **I**). - **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`UICanvas/EconomyHudSection/BodyScroll/Body/SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel`** refreshes on craft success only (no **I**).
- **Panel:** per-recipe **Craft** button (qty **1**); input summary uses cached item **`displayName`** when available. - **Panel:** per-recipe **Craft** button (qty **1**); input summary uses cached item **`displayName`** when available.
Full checklist: [`docs/manual-qa/NEO-74.md`](../docs/manual-qa/NEO-74.md). Full checklist: [`docs/manual-qa/NEO-74.md`](../docs/manual-qa/NEO-74.md).
@ -307,7 +307,7 @@ Capstone proof that Epic 3 prototype gather→craft works **in Godot** without B
**Material minimum:** **11** scrap from one **R** at each of four prototype nodes (+1, +2, +3, +5) → two **`refine_scrap_standard`** → one **`make_field_stim_mk0`**. **Material minimum:** **11** scrap from one **R** at each of four prototype nodes (+1, +2, +3, +5) → two **`refine_scrap_standard`** → one **`make_field_stim_mk0`**.
**HUD paths:** feedback labels stay on **`UICanvas`**; economy block under **`UICanvas/EconomyHudSection/Body/`** (`InventoryLabel`, `SkillProgressionLabel`, `CraftRecipePanel`). **HUD paths:** feedback labels stay on **`UICanvas`**; economy block under **`UICanvas/EconomyHudSection/BodyScroll/Body/`** (`InventoryLabel`, `SkillProgressionLabel`, `CraftRecipePanel`).
Full capstone checklist: [`docs/manual-qa/NEO-75.md`](../docs/manual-qa/NEO-75.md). Full capstone checklist: [`docs/manual-qa/NEO-75.md`](../docs/manual-qa/NEO-75.md).

View File

@ -1303,24 +1303,61 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 3 autowrap_mode = 3
text = "Cooldowns: —" text = "Cooldowns: —"
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas/HudRoot" unique_id=9000014] [node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014]
layout_mode = 2 layout_mode = 1
size_flags_horizontal = 3 anchors_preset = 3
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 8.0
offset_top = -440.0
offset_right = 664.0
offset_bottom = -8.0
grow_horizontal = 2
grow_vertical = 0
script = ExtResource("20_economy_hud") script = ExtResource("20_economy_hud")
[node name="HeaderRow" type="HBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection" unique_id=9000015] [node name="HeaderRow" type="HBoxContainer" parent="UICanvas/EconomyHudSection" unique_id=9000015]
layout_mode = 2 layout_mode = 2
[node name="ToggleButton" type="CheckButton" parent="UICanvas/HudRoot/EconomyHudSection/HeaderRow" unique_id=9000016] [node name="ToggleButton" type="CheckButton" parent="UICanvas/EconomyHudSection/HeaderRow" unique_id=9000016]
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 20 theme_override_font_sizes/font_size = 20
text = "Economy HUD" text = "Economy HUD"
[node name="Body" type="VBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection" unique_id=9000017] [node name="BodyScroll" type="ScrollContainer" parent="UICanvas/EconomyHudSection" unique_id=9000027]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
custom_minimum_size = Vector2(656, 380)
horizontal_scroll_mode = 0
vertical_scroll_mode = 1
follow_focus = true
[node name="Body" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll" unique_id=9000017]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8 theme_override_constants/separation = 8
[node name="InventoryLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000007] [node name="RecipesHeaderLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000028]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 20
text = "Recipes:"
[node name="CraftRecipePanel" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000011]
layout_mode = 2
size_flags_horizontal = 3
script = ExtResource("19_craft_panel")
[node name="RecipeRows" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll/Body/CraftRecipePanel" unique_id=9000013]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="InventoryLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000007]
custom_minimum_size = Vector2(656, 0) custom_minimum_size = Vector2(656, 0)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
@ -1332,7 +1369,7 @@ autowrap_mode = 3
text = "Inventory: (refresh: I) text = "Inventory: (refresh: I)
Loading…" Loading…"
[node name="SkillProgressionLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000009] [node name="SkillProgressionLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000009]
custom_minimum_size = Vector2(656, 0) custom_minimum_size = Vector2(656, 0)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
@ -1344,7 +1381,7 @@ autowrap_mode = 3
text = "Skills: text = "Skills:
Loading…" Loading…"
[node name="GigXpLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000018] [node name="GigXpLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000018]
custom_minimum_size = Vector2(656, 0) custom_minimum_size = Vector2(656, 0)
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
@ -1355,25 +1392,3 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 3 autowrap_mode = 3
text = "Gig XP: text = "Gig XP:
Loading…" Loading…"
[node name="CraftRecipePanel" type="Control" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000011]
custom_minimum_size = Vector2(656, 360)
layout_mode = 2
size_flags_horizontal = 3
script = ExtResource("19_craft_panel")
[node name="ScrollContainer" type="ScrollContainer" parent="UICanvas/HudRoot/EconomyHudSection/Body/CraftRecipePanel" unique_id=9000012]
layout_mode = 1
anchors_preset = 15
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
size_flags_horizontal = 3
theme_override_constants/separation = 8

View File

@ -1,6 +1,7 @@
extends Control extends VBoxContainer
## NEO-74: scrollable prototype recipe list with per-row Craft buttons. ## NEO-74: prototype recipe list with per-row Craft buttons.
## NEO-122: rows live in economy BodyScroll (no nested scroll).
signal craft_requested(recipe_id: String) signal craft_requested(recipe_id: String)
@ -10,15 +11,7 @@ const HudThemeConstants := preload("res://scripts/prototype_hud_theme_constants.
var _item_defs_client: Node = null var _item_defs_client: Node = null
var _craft_buttons: Array[Button] = [] var _craft_buttons: Array[Button] = []
@onready var _scroll: ScrollContainer = $ScrollContainer @onready var _rows: VBoxContainer = $RecipeRows
@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: func setup(item_defs_client: Node) -> void:
@ -26,9 +19,9 @@ func setup(item_defs_client: Node) -> void:
func populate(recipes: Array) -> void: func populate(recipes: Array) -> void:
if not is_instance_valid(_vbox): if not is_instance_valid(_rows):
return return
for child in _vbox.get_children(): for child in _rows.get_children():
child.queue_free() child.queue_free()
_craft_buttons.clear() _craft_buttons.clear()
for row_variant in recipes: for row_variant in recipes:
@ -55,9 +48,9 @@ func populate(recipes: Array) -> void:
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id)) craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id))
btn_row.add_child(craft_btn) btn_row.add_child(craft_btn)
row.add_child(btn_row) row.add_child(btn_row)
_vbox.add_child(row) _rows.add_child(row)
_craft_buttons.append(craft_btn) _craft_buttons.append(craft_btn)
call_deferred("_reset_scroll_after_populate") call_deferred("_scroll_economy_body_to_recipes")
func set_craft_busy(busy: bool) -> void: func set_craft_busy(busy: bool) -> void:
@ -107,16 +100,10 @@ func _apply_recipe_row_label(label: Label) -> void:
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
func _sync_vbox_width() -> void: func _scroll_economy_body_to_recipes() -> void:
if not is_instance_valid(_scroll) or not is_instance_valid(_vbox): var node: Node = get_parent()
return while node != null:
var width: float = _scroll.size.x if node.has_method("scroll_body_to_recipes"):
if width <= 0.0: node.call("scroll_body_to_recipes")
return return
_vbox.custom_minimum_size.x = width node = node.get_parent()
func _reset_scroll_after_populate() -> void:
_sync_vbox_width()
if is_instance_valid(_scroll):
_scroll.scroll_vertical = 0

View File

@ -141,14 +141,14 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel @onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel @onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel @onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel
@onready var _economy_hud_section: VBoxContainer = $UICanvas/HudRoot/EconomyHudSection @onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection
@onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel") @onready var _inventory_label: Label = _economy_hud_section.get_node("BodyScroll/Body/InventoryLabel")
@onready var _gather_feedback_label: Label = $UICanvas/HudRoot/GatherFeedbackLabel @onready var _gather_feedback_label: Label = $UICanvas/HudRoot/GatherFeedbackLabel
@onready var _craft_feedback_label: Label = $UICanvas/HudRoot/CraftFeedbackLabel @onready var _craft_feedback_label: Label = $UICanvas/HudRoot/CraftFeedbackLabel
@onready @onready
var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel") var _skill_progression_label: Label = _economy_hud_section.get_node("BodyScroll/Body/SkillProgressionLabel")
@onready var _gig_xp_label: Label = _economy_hud_section.get_node("Body/GigXpLabel") @onready var _gig_xp_label: Label = _economy_hud_section.get_node("BodyScroll/Body/GigXpLabel")
@onready var _craft_recipe_panel: Node = _economy_hud_section.get_node("Body/CraftRecipePanel") @onready var _craft_recipe_panel: Node = _economy_hud_section.get_node("BodyScroll/Body/CraftRecipePanel")
@onready var _inventory_client: Node = $InventoryClient @onready var _inventory_client: Node = $InventoryClient
@onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _item_defs_client: Node = $ItemDefinitionsClient
@onready var _skill_progression_client: Node = $SkillProgressionClient @onready var _skill_progression_client: Node = $SkillProgressionClient
@ -309,6 +309,8 @@ func _apply_prototype_hud_theme() -> void:
PrototypeHudTheme.apply_to_label(_move_reject_label, true) PrototypeHudTheme.apply_to_label(_move_reject_label, true)
if is_instance_valid(_hud_root): if is_instance_valid(_hud_root):
PrototypeHudTheme.apply_to_hud_root(_hud_root) PrototypeHudTheme.apply_to_hud_root(_hud_root)
if is_instance_valid(_economy_hud_section):
PrototypeHudTheme.apply_to_economy_section(_economy_hud_section)
func _on_authoritative_ack_for_hud(world: Vector3) -> void: func _on_authoritative_ack_for_hud(world: Vector3) -> void:

View File

@ -1,25 +1,70 @@
extends VBoxContainer extends VBoxContainer
## NEO-75: collapsible economy HUD block (inventory, skills, craft panel). ## NEO-75: collapsible economy HUD block (inventory, skills, craft panel).
## NEO-122: pinned to viewport bottom-left so recipes stay on-screen.
const MAX_VIEWPORT_HEIGHT_FRACTION := 0.48
const MIN_BODY_SCROLL_HEIGHT := 180.0
const BOTTOM_MARGIN := 8.0
const SIDE_MARGIN := 8.0
const PANEL_WIDTH := 656.0
@onready var _toggle: CheckButton = $HeaderRow/ToggleButton @onready var _toggle: CheckButton = $HeaderRow/ToggleButton
@onready var _body: VBoxContainer = $Body @onready var _body_scroll: ScrollContainer = $BodyScroll
@onready var _recipes_header: Label = $BodyScroll/Body/RecipesHeaderLabel
func _ready() -> void: func _ready() -> void:
_toggle.button_pressed = true _toggle.button_pressed = true
_body.visible = true _body_scroll.visible = true
_toggle.toggled.connect(_on_toggle_toggled) _toggle.toggled.connect(_on_toggle_toggled)
get_viewport().size_changed.connect(_apply_viewport_layout)
call_deferred("_apply_viewport_layout")
func _on_toggle_toggled(pressed: bool) -> void: func _on_toggle_toggled(pressed: bool) -> void:
_body.visible = pressed _body_scroll.visible = pressed
call_deferred("_apply_viewport_layout")
func set_expanded(expanded: bool) -> void: func set_expanded(expanded: bool) -> void:
_toggle.button_pressed = expanded _toggle.button_pressed = expanded
_body.visible = expanded _body_scroll.visible = expanded
call_deferred("_apply_viewport_layout")
func is_body_visible() -> bool: func is_body_visible() -> bool:
return _body.visible return _body_scroll.visible
func scroll_body_to_recipes() -> void:
if not is_instance_valid(_body_scroll) or not is_instance_valid(_recipes_header):
return
_body_scroll.scroll_vertical = int(_recipes_header.position.y)
func _apply_viewport_layout() -> void:
if not is_instance_valid(_body_scroll):
return
var vp_height: float = get_viewport().get_visible_rect().size.y
var header_height: float = _measure_header_height()
var body_height: float = max(
MIN_BODY_SCROLL_HEIGHT, vp_height * MAX_VIEWPORT_HEIGHT_FRACTION - header_height
)
_body_scroll.custom_minimum_size = Vector2(PANEL_WIDTH, body_height)
var panel_height: float = header_height
if _body_scroll.visible:
panel_height += body_height
offset_left = SIDE_MARGIN
offset_right = SIDE_MARGIN + PANEL_WIDTH
offset_bottom = -BOTTOM_MARGIN
offset_top = -panel_height - BOTTOM_MARGIN
func _measure_header_height() -> float:
if not is_instance_valid(_toggle):
return 32.0
var header_row: Node = _toggle.get_parent()
if header_row is Control:
return max(32.0, (header_row as Control).size.y)
return 32.0

View File

@ -30,8 +30,6 @@ static func apply_to_hud_root(root: VBoxContainer) -> void:
if child is Label: if child is Label:
var label: Label = child as Label var label: Label = child as Label
apply_to_label(label, not Constants.SECONDARY_LABEL_NAMES.has(label.name)) apply_to_label(label, not Constants.SECONDARY_LABEL_NAMES.has(label.name))
elif child.name == "EconomyHudSection":
apply_to_economy_section(child)
static func apply_to_economy_section(section: Node) -> void: static func apply_to_economy_section(section: Node) -> void:

View File

@ -1,8 +1,9 @@
extends GdUnitTestSuite extends GdUnitTestSuite
## NEO-122 follow-up: craft recipe panel layout + scroll reset on populate. ## NEO-122 follow-up: craft recipe panel rows + economy body scroll reset.
const CraftRecipePanel := preload("res://scripts/craft_recipe_panel.gd") const CraftRecipePanel := preload("res://scripts/craft_recipe_panel.gd")
const EconomyHudSection := preload("res://scripts/prototype_economy_hud_section.gd")
static func _sample_recipes() -> Array: static func _sample_recipes() -> Array:
@ -22,20 +23,44 @@ static func _sample_recipes() -> Array:
func _build_panel() -> Control: func _build_panel() -> Control:
var panel: Control = CraftRecipePanel.new() var panel: Control = CraftRecipePanel.new()
panel.custom_minimum_size = Vector2(656, 360) var rows := VBoxContainer.new()
var scroll := ScrollContainer.new() rows.name = "RecipeRows"
scroll.name = "ScrollContainer" panel.add_child(rows)
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) auto_free(panel)
add_child(panel) add_child(panel)
await get_tree().process_frame await get_tree().process_frame
return panel return panel
func _build_panel_in_economy_section() -> Dictionary:
var section: VBoxContainer = EconomyHudSection.new()
var header := HBoxContainer.new()
header.name = "HeaderRow"
var toggle := CheckButton.new()
toggle.name = "ToggleButton"
header.add_child(toggle)
var body_scroll := ScrollContainer.new()
body_scroll.name = "BodyScroll"
var body := VBoxContainer.new()
body.name = "Body"
var recipes_header := Label.new()
recipes_header.name = "RecipesHeaderLabel"
body.add_child(recipes_header)
var panel: Control = CraftRecipePanel.new()
panel.name = "CraftRecipePanel"
var rows := VBoxContainer.new()
rows.name = "RecipeRows"
panel.add_child(rows)
body.add_child(panel)
body_scroll.add_child(body)
section.add_child(header)
section.add_child(body_scroll)
auto_free(section)
add_child(section)
await get_tree().process_frame
return {"section": section, "panel": panel, "body_scroll": body_scroll}
func test_populate_builds_one_row_per_recipe() -> void: func test_populate_builds_one_row_per_recipe() -> void:
# Arrange # Arrange
var panel := await _build_panel() var panel := await _build_panel()
@ -43,22 +68,22 @@ func test_populate_builds_one_row_per_recipe() -> void:
panel.call("populate", _sample_recipes()) panel.call("populate", _sample_recipes())
await get_tree().process_frame await get_tree().process_frame
# Assert # Assert
var vbox: VBoxContainer = panel.get_node("ScrollContainer/VBoxContainer") var rows: VBoxContainer = panel.get_node("RecipeRows")
assert_that(vbox.get_child_count()).is_equal(2) assert_that(rows.get_child_count()).is_equal(2)
func test_populate_resets_scroll_to_top() -> void: func test_populate_scrolls_economy_body_to_recipes() -> void:
# Arrange # Arrange
var panel := await _build_panel() var built: Dictionary = await _build_panel_in_economy_section()
var scroll: ScrollContainer = panel.get_node("ScrollContainer") var panel: Control = built["panel"]
panel.call("populate", _sample_recipes()) var body_scroll: ScrollContainer = built["body_scroll"]
await get_tree().process_frame body_scroll.scroll_vertical = 999
scroll.scroll_vertical = 999
# Act # Act
panel.call("populate", _sample_recipes()) panel.call("populate", _sample_recipes())
await get_tree().process_frame await get_tree().process_frame
await get_tree().process_frame
# Assert # Assert
assert_that(scroll.scroll_vertical).is_equal(0) assert_that(body_scroll.scroll_vertical).is_equal(0)
func test_populate_emits_craft_requested_for_row() -> void: func test_populate_emits_craft_requested_for_row() -> void:
@ -70,8 +95,8 @@ func test_populate_emits_craft_requested_for_row() -> void:
) )
panel.call("populate", _sample_recipes()) panel.call("populate", _sample_recipes())
await get_tree().process_frame await get_tree().process_frame
var vbox: VBoxContainer = panel.get_node("ScrollContainer/VBoxContainer") var rows: VBoxContainer = panel.get_node("RecipeRows")
var first_row: VBoxContainer = vbox.get_child(0) as VBoxContainer var first_row: VBoxContainer = rows.get_child(0) as VBoxContainer
var btn_row: HBoxContainer = first_row.get_child(1) as HBoxContainer var btn_row: HBoxContainer = first_row.get_child(1) as HBoxContainer
var craft_btn: Button = btn_row.get_child(0) as Button var craft_btn: Button = btn_row.get_child(0) as Button
# Act # Act

View File

@ -1,7 +1,7 @@
extends GdUnitTestSuite extends GdUnitTestSuite
## NEO-75: economy HUD collapse toggle shows/hides body container. ## NEO-75: economy HUD collapse toggle shows/hides body scroll container.
## Covers economy HUD node paths via EconomyHudSection.get_node in main.gd. ## NEO-122: BodyScroll/Body paths match main.tscn.
const EconomyHudSection := preload("res://scripts/prototype_economy_hud_section.gd") const EconomyHudSection := preload("res://scripts/prototype_economy_hud_section.gd")
@ -14,13 +14,16 @@ func _build_section() -> Node:
toggle.name = "ToggleButton" toggle.name = "ToggleButton"
toggle.text = "Economy HUD" toggle.text = "Economy HUD"
header.add_child(toggle) header.add_child(toggle)
var body_scroll := ScrollContainer.new()
body_scroll.name = "BodyScroll"
var body := VBoxContainer.new() var body := VBoxContainer.new()
body.name = "Body" body.name = "Body"
var inventory := Label.new() var inventory := Label.new()
inventory.name = "InventoryLabel" inventory.name = "InventoryLabel"
body.add_child(inventory) body.add_child(inventory)
body_scroll.add_child(body)
section.add_child(header) section.add_child(header)
section.add_child(body) section.add_child(body_scroll)
return section return section