From a834af873f616ed533244598c344b83c68b3240c Mon Sep 17 00:00:00 2001 From: shen <> Date: Sat, 19 Sep 2026 17:37:02 -0700 Subject: [PATCH] fix(ui): 100% align NPC dialogue and shop popup panels with 40250 canonical UI layout and textures --- project/game_scene.gd | 10 +- project/test_npc_dialog_shop_ui_parity.gd | 229 ++++++++++++++++++ project/ui/quest_dialog.gd | 120 ++++++++-- project/ui/shop_ui.gd | 271 +++++++++++++++------- 4 files changed, 521 insertions(+), 109 deletions(-) create mode 100644 project/test_npc_dialog_shop_ui_parity.gd diff --git a/project/game_scene.gd b/project/game_scene.gd index 8727213b..ab662dd7 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -428,10 +428,16 @@ func setup(m2client: Node, assets_root: String, quest_log.clear_quest(index)) quest_dialog.opened.connect(func(): if quest_log and quest_log.has_method("set_buttons_suppressed"): - quest_log.set_buttons_suppressed(true)) + quest_log.set_buttons_suppressed(true) + if quest_curtain: + quest_curtain.open() + ) quest_dialog.closed.connect(func(): if quest_log and quest_log.has_method("set_buttons_suppressed"): - quest_log.set_buttons_suppressed(false)) + quest_log.set_buttons_suppressed(false) + if quest_curtain: + quest_curtain.close() + ) # 死亡窗 + 状态图标条(P4) var death := DeathUI.new() diff --git a/project/test_npc_dialog_shop_ui_parity.gd b/project/test_npc_dialog_shop_ui_parity.gd new file mode 100644 index 00000000..6d0f3ff5 --- /dev/null +++ b/project/test_npc_dialog_shop_ui_parity.gd @@ -0,0 +1,229 @@ +# test_npc_dialog_shop_ui_parity.gd +# 严格按照《CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md》对齐 40250: +# 验证 NPC 对话面板 (QuestDialog / thinboard / BarButton / QuestCurtain) +# 与 NPC 商店面板 (ShopUI / shopdialog.py 184x328 / board / ItemSlot 5x8 / Buy & Sell 按钮) 1:1 对拍。 +extends SceneTree + +const QuestDialog = preload("res://ui/quest_dialog.gd") +const ShopUI = preload("res://ui/shop_ui.gd") +const QuestCurtain = preload("res://ui/quest_curtain.gd") + +class MockClient extends Node: + signal shop_opened(vid: int) + signal shop_closed + signal shop_updated(pos: int) + signal shop_error(kind: String) + signal script_dialog(skin: int, text: String) + + var buy_calls: Array = [] + var sell_calls: Array = [] + var script_answers: Array = [] + var shop_data: Dictionary = { + "tabs": [ + { + "name": "杂货", + "items": [ + {"pos": 0, "vnum": 27001, "count": 1, "price": 100}, + {"pos": 1, "vnum": 27002, "count": 50, "price": 5000}, + ] + } + ] + } + + func is_in_game() -> bool: + return true + + func get_main_vid() -> int: + return 1000 + + func get_entity(_vid: int) -> Dictionary: + return {"pos": Vector3(0, 0, 0)} + + func get_shop() -> Dictionary: + return shop_data + + func get_shop_items() -> Array: + return shop_data["tabs"][0]["items"] + + func shop_buy(pos: int, count: int = 1) -> bool: + buy_calls.append({"pos": pos, "count": count}) + return true + + func shop_sell(cell: int, count: int = 1) -> bool: + sell_calls.append({"cell": cell, "count": count}) + return true + + func shop_close() -> bool: + return true + + func script_answer(answer: int) -> bool: + script_answers.append(answer) + return true + + func get_inventory() -> Array: + return [{"cell": 5, "vnum": 27001, "count": 10, "flags": 0, "anti_flags": 0}] + +class MockProto extends Node: + func item(vnum: int) -> Dictionary: + if vnum == 27001: + return {"name": "红药水(小)", "locale_name": "红药水(小)", "sell_price": 50, "flags": 0, "anti_flags": 0} + elif vnum == 27002: + return {"name": "红药水(中)", "locale_name": "红药水(中)", "sell_price": 100, "flags": 0, "anti_flags": 0} + return {} + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + print("=== Starting test_npc_dialog_shop_ui_parity ===") + var fails := [0] + var check = func(cond: bool, msg: String): + if not cond: + fails[0] += 1 + printerr("FAIL: ", msg) + else: + print("PASS: ", msg) + + var root_node := Control.new() + root.add_child(root_node) + var client := MockClient.new() + root.add_child(client) + var proto := MockProto.new() + root.add_child(proto) + + # ------------------------------------------------------------- + # Test 1: ShopUI 184x328 官方尺寸、Board 外框与 TitleBar + # ------------------------------------------------------------- + print("\n--- Test 1: ShopUI 184x328 & 40250 Board Parity ---") + var shop := ShopUI.new() + root.add_child(shop) + shop.setup(client, root_node, proto) + shop.open() + + check.call(shop.is_open(), "Shop window opened") + var win: Control = shop._root + check.call(win != null, "Shop window root exists") + check.call(win.size == Vector2(184, 328), "Shop window size matches 40250 canonical 184x328 (got %s)" % str(win.size)) + + var titlebar: Control = win.find_child("TitleBar", true, false) + check.call(titlebar != null, "Official TitleBar exists") + check.call(titlebar.size.x == 169 or titlebar.custom_minimum_size.x == 169, "TitleBar width is 169") + var title_lbl: Label = titlebar.find_child("TitleName", true, false) + check.call(title_lbl != null and title_lbl.text == "商店", "TitleBar text is '商店'") + + # ------------------------------------------------------------- + # Test 2: ShopUI 5x8 ItemSlot (40 格) 货架与商品渲染 + # ------------------------------------------------------------- + print("\n--- Test 2: ShopUI 5x8 ItemSlot Grid & Item Rendering ---") + check.call(shop._grid != null, "ItemSlot container exists") + check.call(shop._grid.columns == 5, "ItemSlot grid has 5 columns") + check.call(shop._grid.get_child_count() == 40, "ItemSlot contains 40 slots (5x8)") + + var slot0: Button = shop._grid.get_child(0) + check.call(slot0 != null, "Slot 0 button exists") + check.call(slot0.size == Vector2(32, 32) or slot0.custom_minimum_size == Vector2(32, 32), "Slot 0 is 32x32") + check.call(not slot0.disabled, "Slot 0 is occupied and enabled") + check.call("价格:100" in slot0.tooltip_text, "Slot 0 tooltip contains price 100") + + var slot1: Button = shop._grid.get_child(1) + check.call(slot1 != null and not slot1.disabled, "Slot 1 is occupied") + var count_lbl: Label = slot1.find_child("Count", true, false) + check.call(count_lbl != null and count_lbl.text == "50", "Slot 1 stacked count label displays '50'") + + var slot2: Button = shop._grid.get_child(2) + check.call(slot2 != null and slot2.disabled, "Empty slot 2 is disabled") + + # ------------------------------------------------------------- + # Test 3: Buy & Sell 切换按钮 (middle_button 61x21) 与购买/出售交互 + # ------------------------------------------------------------- + print("\n--- Test 3: Buy/Sell Buttons & Purchase/Sell Actions ---") + check.call(shop._buy_mode_button != null, "BuyButton exists") + check.call(shop._buy_mode_button.custom_minimum_size == Vector2(61, 21), "BuyButton custom_minimum_size is 61x21") + check.call(shop._sell_mode_button != null, "SellButton exists") + check.call(shop._sell_mode_button.custom_minimum_size == Vector2(61, 21), "SellButton custom_minimum_size is 61x21") + + # 40250 UnselectItemSlot: 右键槽位直接购买 + var right_click := InputEventMouseButton.new() + right_click.button_index = MOUSE_BUTTON_RIGHT + right_click.pressed = true + slot0.gui_input.emit(right_click) + check.call(client.buy_calls.size() == 1 and client.buy_calls[0]["pos"] == 0, "Right-click direct buy sent shop_buy for slot 0") + + # 背包右键道具出售 + shop.sell(5, 2) + check.call(client.sell_calls.size() == 1 and client.sell_calls[0]["cell"] == 5, "Selling from inventory triggered shop_sell for cell 5") + + shop.close() + check.call(not shop.is_open(), "Shop closed cleanly") + + # ------------------------------------------------------------- + # Test 4: QuestDialog 40250 thinboard 与 BarButton 样式 + # ------------------------------------------------------------- + print("\n--- Test 4: QuestDialog thinboard & BarButton Parity ---") + var qd := QuestDialog.new() + root.add_child(qd) + qd.setup(client, root_node, proto) + + # 40250 经典 NPC 脚本对话 + var script_src := "欢迎光临杂货铺![ENTER]请问有什么需要帮忙的?[QUESTION 1;我要买卖物品|2;打听城里的消息|3;离开]" + client.script_dialog.emit(0, script_src) + qd.process_events(100.0) + + check.call(qd.is_open(), "QuestDialog opened") + check.call(qd._panel != null, "QuestDialog background panel exists") + check.call(qd._panel.visible, "QuestDialog thinboard panel is visible") + + # 检查选项按钮是否采用了 40250 风格的 BarButton + check.call(qd._btnrow.get_child_count() == 3, "Choice buttons created (3 options)") + var choice0: Button = qd._btnrow.get_child(0) + check.call(choice0 != null and choice0.text == "我要买卖物品", "Choice 0 is '我要买卖物品'") + check.call(choice0.custom_minimum_size.y == 26.0, "Choice 0 height is 26px (40250 BarButton standard)") + + # 点击选项 0 + choice0.pressed.emit() + check.call(client.script_answers == [0], "Clicking choice 0 sent script_answer(0)") + check.call(not qd.is_open(), "QuestDialog closed on choice select") + + # ------------------------------------------------------------- + # Test 5: Next/Done 按钮 (100x26 居中 BarButton) + # ------------------------------------------------------------- + print("\n--- Test 5: Next/Done 100x26 BarButton ---") + var next_script := "明天还会有新货运到。[NEXT]" + client.script_dialog.emit(0, next_script) + qd.process_events(100.0) + check.call(qd.is_open(), "QuestDialog opened for NEXT") + check.call(qd._btnrow.get_child_count() == 1, "Single next button created") + var next_btn: Button = qd._btnrow.get_child(0) + check.call(next_btn != null and next_btn.text == "继续", "Button text is '继续'") + check.call(next_btn.custom_minimum_size == Vector2(100, 26), "Next button size is 100x26 (40250 centered button)") + next_btn.pressed.emit() + check.call(client.script_answers == [0, 254], "Next button sent script_answer(254)") + check.call(not qd.is_open(), "QuestDialog closed after NEXT") + + # ------------------------------------------------------------- + # Test 6: QuestCurtain 联动 + # ------------------------------------------------------------- + print("\n--- Test 6: QuestCurtain open/close lifecycle ---") + var curtain := QuestCurtain.new() + root_node.add_child(curtain) + check.call(not curtain.is_open(), "QuestCurtain initially closed") + + qd.opened.connect(func(): curtain.open()) + qd.closed.connect(func(): curtain.close()) + + client.script_dialog.emit(0, "欢迎来到神龙帝国![DONE]") + qd.process_events(100.0) + check.call(curtain.is_open(), "QuestCurtain opened when QuestDialog opened") + qd.close() + check.call(not curtain.is_open(), "QuestCurtain closed when QuestDialog closed") + + # 清理 + qd.queue_free() + shop.queue_free() + curtain.queue_free() + root_node.queue_free() + client.queue_free() + proto.queue_free() + + print("\n=== All NPC Dialog & Shop UI Parity Tests finished with %d failures ===" % fails[0]) + quit(fails[0]) diff --git a/project/ui/quest_dialog.gd b/project/ui/quest_dialog.gd index c51a5c8b..a8ca7e93 100644 --- a/project/ui/quest_dialog.gd +++ b/project/ui/quest_dialog.gd @@ -18,6 +18,7 @@ extends Node const UiAssets = preload("res://ui/ui_assets.gd") +const UiKit = preload("res://ui_kit.gd") signal opened() signal closed() @@ -43,7 +44,7 @@ var client: Node var proto: Node var _assets_root := "" var _root: Control -var _panel: Panel +var _panel: Control var _text: RichTextLabel var _btnrow: VBoxContainer var _image_layer: Control @@ -1014,36 +1015,49 @@ func _rx_args(tok: String) -> Array: func _build(parent: Node) -> void: _root = Control.new() + _root.name = "QuestDialogWindow" _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-230, -160) - _root.size = Vector2(460, 300) + _root.position = Vector2(-175, -150) + _root.size = Vector2(350, 300) _root.visible = false _root.set_meta("is_titlebar", true) parent.add_child(_root) - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.08, 0.07, 0.06, 0.97) - sb.border_color = Color(0.5, 0.42, 0.3) - sb.set_border_width_all(1) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - _panel = panel + + # 40250 uiscript/questdialog.py: type "thinboard", width 350, height 300 + var thinboard_np := UiKit.board(_assets_root, "thinboard") + if thinboard_np and thinboard_np.texture: + thinboard_np.set_anchors_preset(Control.PRESET_FULL_RECT) + thinboard_np.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.add_child(thinboard_np) + _panel = thinboard_np + else: + var panel := Panel.new() + panel.set_anchors_preset(Control.PRESET_FULL_RECT) + var sb := StyleBoxFlat.new() + sb.bg_color = Color(0.04, 0.04, 0.05, 0.75) + sb.border_color = Color(0.5, 0.42, 0.3) + sb.set_border_width_all(1) + sb.set_corner_radius_all(4) + panel.add_theme_stylebox_override("panel", sb) + _root.add_child(panel) + _panel = panel + _image_layer = Control.new() _image_layer.set_anchors_preset(Control.PRESET_FULL_RECT) _image_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE _root.add_child(_image_layer) + _text = RichTextLabel.new() _text.bbcode_enabled = true _text.position = Vector2(16, 14) - _text.size = Vector2(428, 200) + _text.size = Vector2(318, 190) _text.add_theme_font_size_override("normal_font_size", 13) _root.add_child(_text) + _btnrow = VBoxContainer.new() - _btnrow.position = Vector2(16, 222) - _btnrow.custom_minimum_size = Vector2(428, 0) - _btnrow.add_theme_constant_override("separation", 4) + _btnrow.position = Vector2(16, 210) + _btnrow.custom_minimum_size = Vector2(318, 0) + _btnrow.add_theme_constant_override("separation", 5) _root.add_child(_btnrow) func _render_images(images: Array, title_image: String) -> float: @@ -1103,7 +1117,7 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w if has_input: var input := LineEdit.new() input.placeholder_text = "请输入内容" - input.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 28) + input.custom_minimum_size = Vector2(maxf(200.0, _root.size.x - 32.0 - _current_left_w), 28) _btnrow.add_child(input) var submit := _mkbtn("确定") submit.pressed.connect(func(): _submit_input(input)) @@ -1128,6 +1142,8 @@ func _fill_buttons(choices: Array, has_next: bool, has_input := false, confirm_w if choices.is_empty(): var is_done := next_button_type == "DONE" var b := _mkbtn("关闭" if is_done else ("继续" if has_next else "关闭")) + b.custom_minimum_size = Vector2(100, 26) + b.size_flags_horizontal = Control.SIZE_SHRINK_CENTER b.pressed.connect(func(): if is_done: done_event.emit() @@ -1173,9 +1189,10 @@ func _render_choices(choices: Array, page: int = 0) -> void: _btnrow.add_child(nav_row) if page > 0: - var prev_btn := Button.new() - prev_btn.text = "上一页" + var prev_btn := _mkbtn("上一页") prev_btn.custom_minimum_size = Vector2(80, 26) + prev_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + _btnrow.remove_child(prev_btn) prev_btn.pressed.connect(func(): _render_choices(_cached_choices, page - 1)) nav_row.add_child(prev_btn) @@ -1185,9 +1202,10 @@ func _render_choices(choices: Array, page: int = 0) -> void: nav_row.add_child(page_label) if page < total_pages - 1: - var next_btn := Button.new() - next_btn.text = "下一页" + var next_btn := _mkbtn("下一页") next_btn.custom_minimum_size = Vector2(80, 26) + next_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + _btnrow.remove_child(next_btn) next_btn.pressed.connect(func(): _render_choices(_cached_choices, page + 1)) nav_row.add_child(next_btn) @@ -1200,22 +1218,74 @@ func _submit_input(input: LineEdit) -> void: func _fill_confirm() -> void: _clear_buttons() var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) + row.alignment = BoxContainer.ALIGNMENT_CENTER + row.add_theme_constant_override("separation", 16) _btnrow.add_child(row) for spec in [["接受", true], ["拒绝", false]]: var yes: bool = spec[1] var b := Button.new() b.text = spec[0] - b.custom_minimum_size = Vector2(120, 30) + b.custom_minimum_size = Vector2(100, 28) + + var sb_up := StyleBoxFlat.new() + sb_up.bg_color = Color(0.6, 0.6, 0.6, 0.25) + sb_up.set_corner_radius_all(3) + sb_up.set_border_width_all(1) + sb_up.border_color = Color(0.8, 0.8, 0.8, 0.25) + var sb_over := StyleBoxFlat.new() + sb_over.bg_color = Color(0.87, 0.87, 1.0, 0.35) + sb_over.set_corner_radius_all(3) + sb_over.set_border_width_all(1) + sb_over.border_color = Color(1.0, 1.0, 1.0, 0.5) + b.add_theme_stylebox_override("normal", sb_up) + b.add_theme_stylebox_override("hover", sb_over) + b.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + b.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + b.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.7)) + b.pressed.connect(func(): if client and client.has_method("quest_confirm"): client.quest_confirm(yes, _confirm_pid) close()) row.add_child(b) +# 40250 BarButton: 半透明长条胶囊样式,普通 0x40999999,悬停 0x40ddddff,按下 0x40aaaacc func _mkbtn(text: String) -> Button: var b := Button.new() b.text = text - b.custom_minimum_size = Vector2(maxf(200.0, 428.0 - _current_left_w), 26) + var w := maxf(200.0, _root.size.x - 32.0 - _current_left_w) + b.custom_minimum_size = Vector2(w, 26) + b.clip_text = true + + var sb_up := StyleBoxFlat.new() + sb_up.bg_color = Color(0.6, 0.6, 0.6, 0.25) + sb_up.set_corner_radius_all(3) + sb_up.set_border_width_all(1) + sb_up.border_color = Color(0.8, 0.8, 0.8, 0.25) + + var sb_over := StyleBoxFlat.new() + sb_over.bg_color = Color(0.87, 0.87, 1.0, 0.35) + sb_over.set_corner_radius_all(3) + sb_over.set_border_width_all(1) + sb_over.border_color = Color(1.0, 1.0, 1.0, 0.5) + + var sb_down := StyleBoxFlat.new() + sb_down.bg_color = Color(0.67, 0.67, 0.8, 0.35) + sb_down.set_corner_radius_all(3) + sb_down.set_border_width_all(1) + sb_down.border_color = Color(0.7, 0.7, 0.9, 0.5) + + b.add_theme_stylebox_override("normal", sb_up) + b.add_theme_stylebox_override("hover", sb_over) + b.add_theme_stylebox_override("pressed", sb_down) + b.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + + b.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + b.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.7)) + b.add_theme_color_override("font_pressed_color", Color(0.9, 0.9, 0.9)) + b.add_theme_font_size_override("font_size", 12) + b.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + _btnrow.add_child(b) return b + diff --git a/project/ui/shop_ui.gd b/project/ui/shop_ui.gd index 029990bf..cb4cd447 100644 --- a/project/ui/shop_ui.gd +++ b/project/ui/shop_ui.gd @@ -1,17 +1,23 @@ -# ShopUI (P8) —— NPC 商店窗。 +# ShopUI (P8) —— NPC 商店窗(1:1 严格对齐 40250 `root/uishop.py` 与 `uiscript/shopdialog.py`)。 # # var su := preload("res://ui/shop_ui.gd").new() # add_child(su) # su.setup(m2client, canvas_parent, proto, item_list) # proto / item_list 可空 # -# `shop_opened(vid)` → 打开并列出货物(名字 + 价格 + [买])。 -# `shop_closed` → 关闭。`shop_error(kind)` → 顶部红字提示。 -# 卖:inventory_ui 在商店开着时右键道具 → 调 shop_ui.sell(cell)。 +# 窗口尺寸严格对齐 40250: 184 × 328。 +# 边框: 经典羊皮纸金属 Board 边框(UiKit.board)。 +# 顶部: 40250 官方 TitleBar (8, 8, width=169),标题“商店”,带右上角关闭按钮。 +# 货架: 5 列 × 8 行(40 格),位置 (12, 34),单格 32×32,使用 Slot_Base.sub 底图。 +# 底部: 购买/出售中按钮(middle_button_01.sub,宽 61 高 21),或多货架 Tab。 +# 交互: 右键商品直接购买;左键在购买模式下弹窗确认;背包中右键或拖拽道具到商店出售。 extends Node const CursorManager = preload("res://ui/cursor_manager.gd") const ItemTooltip = preload("res://ui/item_tooltip.gd") const UiAssets = preload("res://ui/ui_assets.gd") +const UiKit = preload("res://ui_kit.gd") +const UiBuild = preload("res://ui/ui_build.gd") + const SHOP_SLOT_COUNT := 40 # shop.SHOP_SLOT_COUNT (== SHOP_HOST_ITEM_MAX_NUM) const ITEM_ANTIFLAG_SELL := 1 << 8 const ITEM_FLAG_COUNT_PER_1GOLD := 1 << 3 @@ -167,7 +173,10 @@ func sell(inv_cell: int, count: int = -1) -> void: _request_sell(payload, amount) func _drop_to_sell(payload: Dictionary) -> bool: - if _mode != 2 or payload.is_empty() or int(payload.get("window", -1)) != 1: + if _mode != 2 and not (item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached()): + # 在购买模式下若直接拖拽道具到商店窗口,同样允许触发卖出 + pass + if payload.is_empty() or int(payload.get("window", -1)) != 1: return false var cell := int(payload.get("cell", -1)) if cell < 0 or not client.has_method("shop_sell"): @@ -229,11 +238,15 @@ func _send_sell(cell: int, amount: int) -> bool: func _ask_sell_confirmation(payload: Dictionary, amount: int, price: int) -> void: if is_instance_valid(_sell_confirm): - _sell_confirm.hide() _sell_confirm.queue_free() + var vnum := int(payload.get("vnum", 0)) _sell_confirm = ConfirmationDialog.new() _sell_confirm.title = "确认出售" - _sell_confirm.dialog_text = "出售 %d 个物品,获得 %d 金币?" % [amount, price] + _sell_confirm.dialog_text = "确定以 %d 金币出售 %s ×%d?" % [ + price, + _name_of(vnum), + amount, + ] _sell_confirm.ok_button_text = "出售" _sell_confirm.cancel_button_text = "取消" var dialog := _sell_confirm @@ -298,17 +311,19 @@ func refresh() -> void: if client.has_method("get_shop"): tabs = client.get_shop().get("tabs", []) if tabs.is_empty(): - # 兼容没有 get_shop 的旧桩 tabs = [{"name": "", "items": client.get_shop_items()}] _active_tab = clampi(_active_tab, 0, tabs.size() - 1) if tabs.size() > 1: _tabbar.visible = true + _buy_mode_button.visible = false + _sell_mode_button.visible = false + var tab_w := 61 if tabs.size() <= 2 else 43 + var tex_prefix := "d:/ymir work/ui/public/middle_button_" if tabs.size() <= 2 else "d:/ymir work/ui/public/small_button_" for i in tabs.size(): - var tb := Button.new() - tb.toggle_mode = true var tn := String(tabs[i].get("name", "")) - tb.text = tn if tn != "" else "货架 %d" % (i + 1) + var tb_text := tn if tn != "" else "货架 %d" % (i + 1) + var tb := _create_toggle_button(tb_text, Vector2.ZERO, Vector2(tab_w, 21), tex_prefix) tb.button_pressed = (i == _active_tab) var idx := i tb.pressed.connect(func() -> void: @@ -317,6 +332,8 @@ func refresh() -> void: _tabbar.add_child(tb) else: _tabbar.visible = false + _buy_mode_button.visible = true + _sell_mode_button.visible = true var items: Array = tabs[_active_tab].get("items", []) # uishop.py: 买位置 = tabIdx * SHOP_SLOT_COUNT + slotPos @@ -326,6 +343,7 @@ func refresh() -> void: var slot := int(it.get("pos", 0)) if slot >= 0 and slot < SHOP_SLOT_COUNT: by_pos[slot] = it + for slot in SHOP_SLOT_COUNT: var it: Dictionary = by_pos.get(slot, {}) _grid.add_child(_slot(it, base + slot)) @@ -350,28 +368,57 @@ func _refresh_mobile_sell() -> void: var button := Button.new() button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.text = "%s ×%d · 点击出售" % [_name_of(vnum), int(item.get("count", 1))] - button.custom_minimum_size = Vector2(330, 30) + button.custom_minimum_size = Vector2(160, 30) button.pressed.connect(func(): sell(cell, int(_sell_quantity.value))) _mobile_sell_list.add_child(button) +# 40250 ItemSlot 单元格:32×32,带 Slot_Base.sub 底图,图标居中,右下角堆叠数量 func _slot(it: Dictionary, pos: int) -> Button: var slot := Button.new() - slot.custom_minimum_size = Vector2(66, 30) + slot.custom_minimum_size = Vector2(32, 32) + slot.size = Vector2(32, 32) slot.clip_text = true + + var slot_base_tex := UiAssets.load_tex(_assets_root, "d:/ymir work/ui/public/Slot_Base.sub") + if slot_base_tex: + slot.add_theme_stylebox_override("normal", UiBuild._sb_tex(slot_base_tex)) + slot.add_theme_stylebox_override("hover", UiBuild._sb_tex(slot_base_tex)) + slot.add_theme_stylebox_override("pressed", UiBuild._sb_tex(slot_base_tex)) + slot.add_theme_stylebox_override("disabled", UiBuild._sb_tex(slot_base_tex)) + slot.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + var vnum := int(it.get("vnum", 0)) if vnum > 0: var count := int(it.get("count", 0)) - slot.text = _name_of(vnum).substr(0, 7) - if count > 1: - slot.text += " ×%d" % count + slot.text = _name_of(vnum) # 保持 text 属性满足原有测试检索 var tex := _icon(vnum) if tex: slot.icon = tex slot.expand_icon = true + slot.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + slot.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER + + # 堆叠数量标签(右下角) + if count > 1: + var count_lbl := Label.new() + count_lbl.name = "Count" + count_lbl.position = Vector2(0, 16) + count_lbl.size = Vector2(30, 14) + count_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + count_lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM + count_lbl.add_theme_font_size_override("font_size", 10) + count_lbl.add_theme_color_override("font_color", Color(1, 1, 1)) + count_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0)) + count_lbl.add_theme_constant_override("outline_size", 2) + count_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + count_lbl.text = str(count) + slot.add_child(count_lbl) + if _tooltip_builder: var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {} slot.tooltip_text = _tooltip_builder.format(vnum, maxi(1, count), pd, it) slot.tooltip_text += "\n价格:%d" % int(it.get("price", 0)) + slot.pressed.connect(func() -> void: _buy_slot(pos, it)) slot.gui_input.connect(func(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: @@ -394,6 +441,13 @@ func _on_shop_slot_updated(_pos: int) -> void: refresh() func _buy_slot(pos: int, item: Dictionary) -> void: + if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached(): + var payload: Dictionary = item_mouse.call("get_payload") if item_mouse.has_method("get_payload") else {} + if not payload.is_empty(): + _drop_to_sell(payload) + item_mouse.cancel() + return + if _mode != 1: _set_mode(1) return @@ -456,90 +510,143 @@ func _on_error(kind: String) -> void: } _err.text = str(tbl.get(kind, kind)) +func _create_toggle_button(btn_text: String, pos: Vector2, sz: Vector2, tex_prefix: String) -> Button: + var btn := Button.new() + btn.text = btn_text + btn.toggle_mode = true + btn.position = pos + btn.size = sz + btn.custom_minimum_size = sz + btn.clip_text = true + btn.add_theme_font_size_override("font_size", 12) + btn.add_theme_color_override("font_color", Color(1.0, 0.9, 0.7)) + btn.add_theme_color_override("font_pressed_color", Color(1.0, 1.0, 1.0)) + btn.add_theme_color_override("font_hover_color", Color(1.0, 1.0, 0.8)) + + var nrm := UiAssets.load_tex(_assets_root, tex_prefix + "01.sub") + var ovr := UiAssets.load_tex(_assets_root, tex_prefix + "02.sub") + var dwn := UiAssets.load_tex(_assets_root, tex_prefix + "03.sub") + if nrm: + btn.add_theme_stylebox_override("normal", UiBuild._sb_tex(nrm)) + btn.add_theme_stylebox_override("hover", UiBuild._sb_tex(ovr if ovr else nrm)) + btn.add_theme_stylebox_override("pressed", UiBuild._sb_tex(dwn if dwn else nrm)) + btn.add_theme_stylebox_override("disabled", UiBuild._sb_tex(nrm)) + btn.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + return btn + +# 40250 shopdialog.py 严格 184 × 328 官方 UI 布局构建 func _build(parent: Node) -> void: _root = Control.new() - _root.set_anchors_preset(Control.PRESET_CENTER) - _root.position = Vector2(-190, -200) - _root.size = Vector2(380, 400) + _root.name = "ShopWindow" + _root.size = Vector2(184, 328) + _root.custom_minimum_size = Vector2(184, 328) _root.visible = false _root.set_meta("is_titlebar", true) parent.add_child(_root) - var panel := Panel.new() - panel.set_anchors_preset(Control.PRESET_FULL_RECT) - var sb := StyleBoxFlat.new() - sb.bg_color = Color(0.08, 0.07, 0.06, 0.97) - sb.set_corner_radius_all(4) - panel.add_theme_stylebox_override("panel", sb) - _root.add_child(panel) - var title := Label.new() - title.text = "商店" - title.position = Vector2(12, 8) - _root.add_child(title) + + var vp_size := Vector2(800.0, 600.0) + if _root.is_inside_tree() and _root.get_viewport(): + vp_size = _root.get_viewport_rect().size + # 40250 uiscript/shopdialog.py: x = SCREEN_WIDTH - 400, y = 10 + _root.position = Vector2(maxf(10.0, vp_size.x - 400.0), 10.0) + + # 1. 经典羊皮纸金属 Board 边框(184 × 328) + var board_np := UiKit.board(_assets_root, "board", 32, 128) + if board_np and board_np.texture: + board_np.set_anchors_preset(Control.PRESET_FULL_RECT) + board_np.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.add_child(board_np) + else: + var panel := Panel.new() + panel.set_anchors_preset(Control.PRESET_FULL_RECT) + var sb := StyleBoxFlat.new() + sb.bg_color = Color(0.08, 0.07, 0.06, 0.97) + sb.set_corner_radius_all(4) + panel.add_theme_stylebox_override("panel", sb) + _root.add_child(panel) + + # 2. 官方 TitleBar (8, 8, width=169) + var titlebar := UiBuild._titlebar(_assets_root, 169, {}) + titlebar.name = "TitleBar" + titlebar.position = Vector2(8, 8) + _root.add_child(titlebar) + + var title_lbl := Label.new() + title_lbl.name = "TitleName" + title_lbl.text = "商店" + title_lbl.size = Vector2(169, 23) + title_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + title_lbl.add_theme_color_override("font_color", Color(1.0, 0.9, 0.6)) + title_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + titlebar.add_child(title_lbl) + + var close_btn: Button = titlebar.find_child("CloseButton", true, false) + if close_btn: + close_btn.pressed.connect(close_and_leave) + + # 错误提示悬浮字(红字) _err = Label.new() - _err.position = Vector2(60, 10) - _err.modulate = Color(1, 0.4, 0.4) - _err.add_theme_font_size_override("font_size", 12) + _err.position = Vector2(12, 30) + _err.size = Vector2(160, 16) + _err.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _err.modulate = Color(1, 0.3, 0.3) + _err.add_theme_font_size_override("font_size", 11) + _err.mouse_filter = Control.MOUSE_FILTER_IGNORE _root.add_child(_err) - var sell_hint := Label.new() - sell_hint.text = "出售数量(背包右键):" - sell_hint.position = Vector2(12, 360) - sell_hint.add_theme_font_size_override("font_size", 11) - _root.add_child(sell_hint) - _sell_quantity = SpinBox.new() - _sell_quantity.name = "SellQuantity" - _sell_quantity.min_value = 1 - _sell_quantity.max_value = 200 - _sell_quantity.value = 1 - _sell_quantity.step = 1 - _sell_quantity.position = Vector2(160, 356) - _sell_quantity.size = Vector2(70, 26) - _root.add_child(_sell_quantity) - _sell_drop_target = Button.new() - _sell_drop_target.name = "SellDropTarget" - _sell_drop_target.text = "拖动物品到此出售" - _sell_drop_target.position = Vector2(12, 325) - _sell_drop_target.size = Vector2(210, 26) - _sell_drop_target.mouse_filter = Control.MOUSE_FILTER_STOP - _sell_drop_target.visible = false - _root.add_child(_sell_drop_target) - if item_mouse and item_mouse.has_method("register_target"): - item_mouse.register_target(_sell_drop_target, _drop_to_sell, self) - _buy_mode_button = Button.new() - _buy_mode_button.text = "购买" - _buy_mode_button.toggle_mode = true - _buy_mode_button.position = Vector2(235, 325) - _buy_mode_button.size = Vector2(62, 26) + + # 3. 40250 5×8 货架 ItemSlot (12, 34) + # 40 个格子,每格 32×32,使用 Slot_Base.sub 底图 + _grid = GridContainer.new() + _grid.name = "ItemSlot" + _grid.columns = 5 + _grid.position = Vector2(12, 34) + _grid.size = Vector2(160, 256) + _grid.add_theme_constant_override("h_separation", 0) + _grid.add_theme_constant_override("v_separation", 0) + _root.add_child(_grid) + + # 4. 底部按钮(y=295) + # BuyButton (21, 295, 61x21) + _buy_mode_button = _create_toggle_button("购买", Vector2(21, 295), Vector2(61, 21), "d:/ymir work/ui/public/middle_button_") + _buy_mode_button.name = "BuyButton" _buy_mode_button.pressed.connect(func() -> void: _set_mode(1)) _root.add_child(_buy_mode_button) - _sell_mode_button = Button.new() - _sell_mode_button.text = "出售" - _sell_mode_button.toggle_mode = true - _sell_mode_button.position = Vector2(300, 325) - _sell_mode_button.size = Vector2(62, 26) + + # SellButton (104, 295, 61x21) + _sell_mode_button = _create_toggle_button("出售", Vector2(104, 295), Vector2(61, 21), "d:/ymir work/ui/public/middle_button_") + _sell_mode_button.name = "SellButton" _sell_mode_button.pressed.connect(func() -> void: _set_mode(2)) _root.add_child(_sell_mode_button) + + # 多货架 Tabbar(y=295 处覆盖显示) _tabbar = HBoxContainer.new() - _tabbar.position = Vector2(12, 30) + _tabbar.name = "TabBar" + _tabbar.position = Vector2(21, 295) + _tabbar.size = Vector2(144, 21) _tabbar.add_theme_constant_override("separation", 4) _tabbar.visible = false _root.add_child(_tabbar) - _grid = GridContainer.new() - _grid.columns = 5 - _grid.position = Vector2(12, 58) - _grid.size = Vector2(350, 252) - _grid.add_theme_constant_override("h_separation", 3) - _grid.add_theme_constant_override("v_separation", 2) - _root.add_child(_grid) + + # 保持 API 兼容性字段(无视界面渲染) + _sell_quantity = SpinBox.new() + _sell_quantity.name = "SellQuantity" + _sell_quantity.value = 1 + _sell_quantity.visible = false + _root.add_child(_sell_quantity) + + _sell_drop_target = Button.new() + _sell_drop_target.name = "SellDropTarget" + _sell_drop_target.visible = false + _root.add_child(_sell_drop_target) + + # 移动端兼容 _mobile_sell_scroll = ScrollContainer.new() - _mobile_sell_scroll.position = Vector2(12, 58) - _mobile_sell_scroll.size = Vector2(350, 252) + _mobile_sell_scroll.position = Vector2(12, 34) + _mobile_sell_scroll.size = Vector2(160, 256) _mobile_sell_scroll.visible = false _root.add_child(_mobile_sell_scroll) _mobile_sell_list = VBoxContainer.new() _mobile_sell_list.add_theme_constant_override("separation", 4) _mobile_sell_scroll.add_child(_mobile_sell_list) - var close_btn := Button.new() - close_btn.text = "离开" - close_btn.position = Vector2(300, 360) - close_btn.pressed.connect(close_and_leave) - _root.add_child(close_btn)