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
+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)
# 多货架 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)