fix(ui): 100% align NPC dialogue and shop popup panels with 40250 canonical UI layout and textures

This commit is contained in:
shen
2026-09-19 17:37:02 -07:00
parent 5d43682498
commit a834af873f
4 changed files with 521 additions and 109 deletions
+8 -2
View File
@@ -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()
+229
View File
@@ -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])
+95 -25
View File
@@ -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
+189 -82
View File
@@ -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)
# 多货架 Tabbary=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)