Files
mtgodot-poc/project/ui/mall_ui.gd
T
shenandshen 66d217b313 feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复
- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

359 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MallUI (첫 버전) —— 道具商城仓库窗(창고몰 / item-mall storage)。
#
# var mu := preload("res://ui/mall_ui.gd").new()
# add_child(mu)
# mu.setup(m2client, canvas_parent, proto) # proto 可空
#
# `mall_opened` → 显示;`mall_changed` → 刷新。列出商城道具 + [取出](放进背包第一个空格)。
extends Node
const ItemTooltip = preload("res://ui/item_tooltip.gd")
var client: Node
var proto: Node
var _tooltip_builder: RefCounted
var _root: Control
var _list: VBoxContainer
var _title: Label
var _status: Label
var _password_dialog: ConfirmationDialog
var item_mouse: Node
const MALL_WINDOW := 4
const MALL_PAGE_SLOTS := 45
const USE_MALL_LIMIT_RANGE_M := 10.0
var _grid: GridContainer
var _cells: Dictionary = {}
var _page_label: Label
var _page := 0
var _mobile_mode := false
var _open_char_pos := Vector3.ZERO
var _has_open_pos := false
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
_tooltip_builder = ItemTooltip.new()
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
_build(parent)
if client.has_signal("mall_opened"):
client.mall_opened.connect(func(_s): open())
if client.has_signal("mall_closed"):
client.mall_closed.connect(_close_local)
if client.has_signal("mall_changed"):
client.mall_changed.connect(refresh)
if client.has_signal("mall_password_required"):
client.mall_password_required.connect(_ask_password)
func is_open() -> bool:
return _root != null and _root.visible
func _get_main_char_pos() -> Variant:
if client == null:
return null
var vid := 0
if client.has_method("get_main_vid"):
vid = int(client.get_main_vid())
if vid != 0 and client.has_method("get_entity"):
var e: Dictionary = client.get_entity(vid)
if not e.is_empty() and e.has("pos"):
return e["pos"]
return null
func _process(_delta: float) -> void:
if not is_open() or not _has_open_pos:
return
var cur = _get_main_char_pos()
if cur is Vector3:
if _open_char_pos.distance_to(cur) > USE_MALL_LIMIT_RANGE_M:
close()
func open() -> void:
if _root:
_root.visible = true
var p = _get_main_char_pos()
if p is Vector3:
_open_char_pos = p
_has_open_pos = true
refresh()
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
func get_mobile_window() -> Control:
return _root
func close() -> void:
# 对齐原版 MallWindow.Close():先发 /mall_close 让服务器关掉商城仓库。
if client and client.has_method("mall_close") and is_open():
client.mall_close()
_close_local()
func _close_local() -> void:
_has_open_pos = false
if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self)
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
item_mouse.cancel()
if _root:
_root.visible = false
_page = 0
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = null
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
var d: Dictionary = proto.item(vnum)
var n := String(d.get("locale_name", d.get("name", "")))
if n != "":
return n
return "#%d" % vnum
func _tooltip_for(item: Dictionary) -> String:
var vnum := int(item.get("vnum", 0))
if _tooltip_builder:
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
return _tooltip_builder.format(vnum, int(item.get("count", 1)), pd, item) + "\n拖到背包取出"
return "%s\n#%d ×%d\n拖到背包取出" % [_name_of(vnum), vnum, int(item.get("count", 1))]
func _first_free_inv() -> int:
var used := {}
for it in client.get_inventory():
used[int(it.get("cell", -1))] = true
for i in range(90):
if not used.has(i):
return i
return -1
func refresh() -> void:
if client == null or _root == null:
return
_root.visible = client.is_mall_open()
if not _root.visible:
return
_status.text = ""
var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS))))
_page = clampi(_page, 0, pages - 1)
_title.text = "道具商城仓库(第 %d/%d 页)" % [_page + 1, pages]
_page_label.text = "%d / %d" % [_page + 1, pages]
for c in _list.get_children():
c.queue_free()
_render_grid()
var items: Array = client.get_mall_items()
if items.is_empty():
var e := Label.new()
e.text = "(空)"
_list.add_child(e)
return
for it in items:
_list.add_child(_row(it))
func _render_grid() -> void:
if _grid == null:
return
for pos in _cells:
var cell: Button = _cells[pos]
cell.text = ""
cell.tooltip_text = ""
cell.set_meta("vnum", 0)
cell.set_meta("count", 0)
var items: Array = client.get_mall_items() if client and client.has_method("get_mall_items") else []
for it in items:
var absolute_pos := int(it.get("cell", -1))
var pos := absolute_pos - _page * MALL_PAGE_SLOTS
if not _cells.has(pos):
continue
var vnum := int(it.get("vnum", 0))
var count := int(it.get("count", 1))
var cell: Button = _cells[pos]
cell.text = "%s%s" % [_name_of(vnum).substr(0, 7), (" ×%d" % count) if count > 1 else ""]
cell.tooltip_text = _tooltip_for(it)
cell.set_meta("vnum", vnum)
cell.set_meta("count", count)
func _drop_to_slot(_payload: Dictionary, _local_pos: int) -> bool:
if is_instance_valid(_status):
_status.text = "无法将物品放入商城仓库!"
if item_mouse and item_mouse.has_method("cancel"):
item_mouse.cancel()
return false
func _on_grid_input(local_pos: int, event: InputEvent) -> void:
if not (event is InputEventMouseButton and event.pressed):
return
if event.button_index == MOUSE_BUTTON_RIGHT:
var mall_pos := _page * MALL_PAGE_SLOTS + local_pos
var target := _first_free_inv()
if target < 0:
if is_instance_valid(_status):
_status.text = "背包已满"
return
if client and client.has_method("mall_checkout"):
client.mall_checkout(mall_pos, 1, target)
return
elif event.button_index == MOUSE_BUTTON_LEFT:
if item_mouse and item_mouse.has_method("is_attached") and item_mouse.is_attached():
if is_instance_valid(_status):
_status.text = "无法将物品放入商城仓库!"
item_mouse.cancel()
return
if _mobile_mode:
_on_mobile_grid_tap(local_pos)
return
if not item_mouse or not item_mouse.has_method("attach_item"):
return
var cell: Button = _cells[local_pos]
var vnum := int(cell.get_meta("vnum", 0))
if vnum == 0:
return
item_mouse.attach_item(MALL_WINDOW, _page * MALL_PAGE_SLOTS + local_pos, vnum,
int(cell.get_meta("count", 1)), null, "mall")
func _on_mobile_grid_tap(local_pos: int) -> void:
if not _mobile_mode or client == null or not _cells.has(local_pos):
return
var cell: Button = _cells[local_pos]
var vnum := int(cell.get_meta("vnum", 0))
if vnum == 0 or not client.has_method("mall_checkout"):
return
var target := _first_free_inv()
if target < 0:
_status.text = "背包已满"
return
if not client.mall_checkout(_page * MALL_PAGE_SLOTS + local_pos, 1, target):
_status.text = "取出请求发送失败"
func _change_page(delta: int) -> void:
var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS))))
_page = clampi(_page + delta, 0, pages - 1)
refresh()
func _ask_password() -> void:
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = ConfirmationDialog.new()
_password_dialog.title = "商城密码"
_password_dialog.dialog_text = "请输入 16 位密码"
_password_dialog.ok_button_text = "确认"
_password_dialog.cancel_button_text = "取消"
var edit := LineEdit.new()
edit.secret = true
edit.max_length = 6
edit.placeholder_text = "密码"
edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
edit.custom_minimum_size = Vector2(360, 44) if _mobile_mode else Vector2(220, 28)
_password_dialog.add_child(edit)
_password_dialog.confirmed.connect(func() -> void:
if not client.mall_password(edit.text) and is_instance_valid(_status):
_status.text = "密码格式无效"
_password_dialog.queue_free()
_password_dialog = null)
_password_dialog.canceled.connect(func() -> void:
_password_dialog.queue_free()
_password_dialog = null)
if _mobile_mode:
_password_dialog.set_meta("mobile_modal", true)
_password_dialog.set_meta("mobile_title", "商城密码")
_password_dialog.min_size = Vector2i(460, 230)
_password_dialog.size = Vector2i(460, 230)
_root.add_child(_password_dialog)
if _mobile_mode:
_password_dialog.popup_centered(Vector2i(460, 230))
else:
_password_dialog.popup_centered()
func _row(it: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(300, 0)
var vnum := int(it.get("vnum", 0))
var count := int(it.get("count", 1))
var cell := int(it.get("cell", 0))
var nm := Label.new()
nm.text = "%s ×%d" % [_name_of(vnum), count]
nm.custom_minimum_size = Vector2(220, 0)
nm.add_theme_font_size_override("font_size", 12)
row.add_child(nm)
if item_mouse:
nm.mouse_filter = Control.MOUSE_FILTER_STOP
nm.gui_input.connect(func(e: InputEvent):
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed \
and item_mouse.has_method("attach_item"):
item_mouse.attach_item(MALL_WINDOW, cell, vnum, count, null, "mall"))
var out := Button.new()
out.text = "取出"
out.pressed.connect(func() -> void:
var target := _first_free_inv()
if target < 0:
_status.text = "背包已满"
return
if not client.mall_checkout(cell, 1, target):
_status.text = "取出请求发送失败")
row.add_child(out)
return row
func _build(parent: Node) -> void:
_root = Panel.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(140, -180)
_root.custom_minimum_size = Vector2(340, 360)
_root.size = Vector2(340, 360)
_root.visible = false
parent.add_child(_root)
var box := VBoxContainer.new()
box.position = Vector2(16, 14)
box.custom_minimum_size = Vector2(308, 0)
box.add_theme_constant_override("separation", 6)
_root.add_child(box)
_title = Label.new()
_title.text = "道具商城仓库"
_title.add_theme_font_size_override("font_size", 16)
box.add_child(_title)
var pager := HBoxContainer.new()
pager.position = Vector2(230, 0)
pager.size = Vector2(90, 24)
var prev := Button.new()
prev.text = ""
prev.custom_minimum_size = Vector2(28, 24)
prev.pressed.connect(func(): _change_page(-1))
pager.add_child(prev)
_page_label = Label.new()
_page_label.custom_minimum_size = Vector2(40, 24)
_page_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
pager.add_child(_page_label)
var next := Button.new()
next.text = ""
next.custom_minimum_size = Vector2(28, 24)
next.pressed.connect(func(): _change_page(1))
pager.add_child(next)
_root.add_child(pager)
_status = Label.new()
_status.add_theme_font_size_override("font_size", 11)
_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
box.add_child(_status)
var sc := ScrollContainer.new()
sc.custom_minimum_size = Vector2(308, 280)
box.add_child(sc)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 4)
sc.add_child(_list)
_list.visible = false
_grid = GridContainer.new()
_grid.columns = 5
_grid.position = Vector2(16, 58)
_grid.add_theme_constant_override("h_separation", 4)
_grid.add_theme_constant_override("v_separation", 4)
_root.add_child(_grid)
for pos in MALL_PAGE_SLOTS:
var cell := Button.new()
cell.custom_minimum_size = Vector2(58, 30)
cell.add_theme_font_size_override("font_size", 9)
cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e))
cell.pressed.connect(func(): _on_mobile_grid_tap(pos))
_grid.add_child(cell)
_cells[pos] = cell
var close := Button.new()
close.text = "关闭"
close.pressed.connect(func() -> void: _root.visible = false)
box.add_child(close)