diff --git a/scripts/scenes/battle_scene.gd b/scripts/scenes/battle_scene.gd index 5071e5d..bde90e8 100644 --- a/scripts/scenes/battle_scene.gd +++ b/scripts/scenes/battle_scene.gd @@ -60,6 +60,7 @@ const DIALOGUE_COLUMN_SIZE := Vector2(820, 186) const DIALOGUE_TEXT_SIZE := Vector2(800, 104) const CAMP_TALK_PORTRAIT_SIZE := Vector2(58, 58) const SHOP_MERCHANT_BADGE_SIZE := Vector2(50, 50) +const SHOP_ITEM_ICON_SIZE := Vector2(48, 48) const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104) const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100) const POST_MOVE_MENU_SIZE := Vector2(228, 120) @@ -5076,11 +5077,14 @@ func _rebuild_shop_buy_list() -> void: var purchased := campaign_state.get_shop_purchase_count(active_scenario_id, normalized_id) var remaining := stock_limit - purchased if stock_limit >= 0 else -1 var buy_button := Button.new() - buy_button.text = _format_shop_item_button_text(normalized_id, item, price, owned, stock_limit, remaining) - _apply_item_button_icon(buy_button, item) + var detail_text := _format_shop_buy_item_detail_text(normalized_id, item, price, owned, stock_limit, remaining) + buy_button.text = _format_shop_buy_action_text(normalized_id, item, price) + buy_button.custom_minimum_size = Vector2(500, 32) + buy_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + buy_button.tooltip_text = _format_shop_item_tooltip_text(_format_shop_item_button_text(normalized_id, item, price, owned, stock_limit, remaining), detail_text) buy_button.disabled = campaign_state.gold < price or (stock_limit >= 0 and remaining <= 0) buy_button.pressed.connect(_on_shop_item_pressed.bind(normalized_id)) - shop_list.add_child(buy_button) + _add_shop_item_row(normalized_id, item, buy_button, detail_text) func _rebuild_shop_sell_list() -> void: @@ -5098,10 +5102,13 @@ func _rebuild_shop_sell_list() -> void: var sale_price := _sale_price_for_item(item) var owned := int(inventory.get(normalized_id, 0)) var sell_button := Button.new() - sell_button.text = _format_shop_sell_item_button_text(normalized_id, item, sale_price, owned) - _apply_item_button_icon(sell_button, item) + var detail_text := _format_shop_sell_item_detail_text(normalized_id, item, sale_price, owned) + sell_button.text = _format_shop_sell_action_text(normalized_id, item, sale_price) + sell_button.custom_minimum_size = Vector2(500, 32) + sell_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + sell_button.tooltip_text = _format_shop_item_tooltip_text(_format_shop_sell_item_button_text(normalized_id, item, sale_price, owned), detail_text) sell_button.pressed.connect(_on_shop_sell_item_pressed.bind(normalized_id)) - shop_list.add_child(sell_button) + _add_shop_item_row(normalized_id, item, sell_button, detail_text) func _clear_shop_list() -> void: @@ -5159,6 +5166,101 @@ func _make_initials_badge(label_text: String, badge_size: Vector2, font_size: in return panel +func _add_shop_item_row(item_id: String, item: Dictionary, action_button: Button, detail_text: String) -> void: + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(580, 58) + row.add_theme_constant_override("separation", 8) + shop_list.add_child(row) + + row.add_child(_make_shop_item_icon_panel(item_id, item)) + + var column := VBoxContainer.new() + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + column.add_theme_constant_override("separation", 3) + row.add_child(column) + + column.add_child(action_button) + + var detail_label := Label.new() + detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + detail_label.custom_minimum_size = Vector2(500, 20) + detail_label.text = detail_text + column.add_child(detail_label) + + +func _make_shop_item_icon_panel(item_id: String, item: Dictionary) -> PanelContainer: + var icon_panel := PanelContainer.new() + icon_panel.custom_minimum_size = SHOP_ITEM_ICON_SIZE + + var stack := Control.new() + stack.custom_minimum_size = SHOP_ITEM_ICON_SIZE + icon_panel.add_child(stack) + + var icon_rect := TextureRect.new() + icon_rect.set_anchors_preset(Control.PRESET_FULL_RECT) + icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + stack.add_child(icon_rect) + + var initials_label := Label.new() + initials_label.set_anchors_preset(Control.PRESET_FULL_RECT) + initials_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + initials_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + initials_label.add_theme_font_size_override("font_size", 16) + stack.add_child(initials_label) + + var texture := _load_art_texture(str(item.get("icon", ""))) + var has_texture := texture != null + icon_rect.texture = texture + icon_rect.visible = has_texture + initials_label.text = _dialogue_portrait_initials(_item_display_name(item_id)) + initials_label.visible = not has_texture + return icon_panel + + +func _format_shop_buy_action_text(item_id: String, _item: Dictionary, price: int) -> String: + return "Buy %s %dG" % [_item_display_name(item_id), price] + + +func _format_shop_sell_action_text(item_id: String, _item: Dictionary, sale_price: int) -> String: + return "Sell %s %dG" % [_item_display_name(item_id), sale_price] + + +func _format_shop_buy_item_detail_text(_item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String: + var parts := _shop_item_common_detail_parts(item, owned) + if stock_limit >= 0: + parts.append("Stock %d/%d" % [max(0, remaining), stock_limit]) + if stock_limit >= 0 and remaining <= 0: + parts.append("Sold out") + elif campaign_state.gold < price: + parts.append("Need %dG" % (price - campaign_state.gold)) + return _join_strings(parts, " | ") + + +func _format_shop_sell_item_detail_text(_item_id: String, item: Dictionary, _sale_price: int, owned: int) -> String: + var parts := _shop_item_common_detail_parts(item, owned) + parts.append("Half-price sale") + return _join_strings(parts, " | ") + + +func _shop_item_common_detail_parts(item: Dictionary, owned: int) -> Array: + var parts := [] + var effect_text := _format_shop_item_effect_text(item) + if not effect_text.is_empty(): + parts.append(effect_text) + var kind := str(item.get("kind", "item")).capitalize() + if not kind.is_empty(): + parts.append(kind) + parts.append("Owned x%d" % owned) + return parts + + +func _format_shop_item_tooltip_text(primary_text: String, detail_text: String) -> String: + if detail_text.is_empty(): + return primary_text + return "%s\n%s" % [primary_text, detail_text] + + func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String: var unavailable := "" if stock_limit >= 0 and remaining <= 0: diff --git a/tools/smoke_visual_assets.gd b/tools/smoke_visual_assets.gd index 8452696..e2c3ece 100644 --- a/tools/smoke_visual_assets.gd +++ b/tools/smoke_visual_assets.gd @@ -195,6 +195,29 @@ func _check_scene_texture_loading(failures: Array[String]) -> void: var merchant_notice := scene._format_shop_merchant_notice_text(scene.state.get_shop_merchant()) if not merchant_notice.contains("Camp Merchant") or not merchant_notice.contains("Roadside stores"): failures.append("shop merchant notice should include name and flavor lines") + var bean: Dictionary = scene.state.get_item_def("bean") + var bean_buy_detail := scene._format_shop_buy_item_detail_text("bean", bean, int(bean.get("price", 0)), 0) + if not bean_buy_detail.contains("Heal 20") or not bean_buy_detail.contains("Owned x0"): + failures.append("shop buy detail should summarize item effect and owned count") + scene.campaign_state.gold = 10 + var need_gold_detail := scene._format_shop_buy_item_detail_text("bean", bean, int(bean.get("price", 0)), 0) + if not need_gold_detail.contains("Need 40G"): + failures.append("shop buy detail should explain missing gold") + var sold_out_detail := scene._format_shop_buy_item_detail_text("bean", bean, int(bean.get("price", 0)), 2, 3, 0) + if not sold_out_detail.contains("Stock 0/3") or not sold_out_detail.contains("Sold out"): + failures.append("shop buy detail should explain exhausted finite stock") + var sell_detail := scene._format_shop_sell_item_detail_text("bean", bean, 25, 2) + if not sell_detail.contains("Half-price sale") or not sell_detail.contains("Owned x2"): + failures.append("shop sell detail should summarize sale rule and owned count") + var bean_action := scene._format_shop_buy_action_text("bean", bean, int(bean.get("price", 0))) + if not bean_action.contains("Buy Bean") or not bean_action.contains("50G"): + failures.append("shop buy action should be concise and priced") + var bean_tooltip := scene._format_shop_item_tooltip_text( + scene._format_shop_item_button_text("bean", bean, int(bean.get("price", 0)), 0), + bean_buy_detail + ) + if not bean_tooltip.contains("\n") or not bean_tooltip.contains("Owned x0"): + failures.append("shop item tooltip should preserve full item detail") scene.free() _check_scenario_background_texture_loading(failures)