Files
mtgodot-poc/project/ui/exchange_ui.gd
T
shenandshen c93894313a fix: 装备属性面板避让逻辑 + 多项功能更新
- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
2026-09-21 16:38:59 -07:00

530 lines
18 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.
# ExchangeUI (P8) —— 交易 / 换货窗。
#
# var xu := preload("res://ui/exchange_ui.gd").new()
# add_child(xu)
# xu.setup(m2client, canvas_parent, proto) # proto 可空
#
# 两栏:我方 / 对方,各列道具行 + 金币。底部:金币输入 + [放金币]、[接受]、[取消]。
# `exchange_changed` 刷新(active=false 时自动关闭)。
# inventory_ui 在交易开着时右键道具 → 调 xu.offer(win, cell)。
extends Node
const ItemTooltip = preload("res://ui/item_tooltip.gd")
const ItemTooltipBridge = preload("res://ui/item_tooltip_bridge.gd")
var client: Node
var proto: Node
var _tooltip_builder: RefCounted
var _tooltip_bridge: RefCounted
var ui: CanvasLayer
var _root: Control
var _self_box: VBoxContainer
var _peer_box: VBoxContainer
var _self_gold: Label
var _peer_gold: Label
var _status: Label
var _accept_btn: Button
var _gold_input: LineEdit
var _next_display := 0
var _offered_cells := {}
var _self_grid: GridContainer
var _exchange_cells: Dictionary = {}
var item_mouse: Node
var _mobile_mode := false
var _mobile_inventory_scroll: ScrollContainer
var _mobile_inventory_list: VBoxContainer
const EXCHANGE_SLOT_COUNT := 12
const INVENTORY_WINDOW := 1
const EXCHANGE_LIMIT_RANGE := 1000.0
const ITEM_ANTIFLAG_GIVE := 1 << 13
var _accept_pending := false
var _anchor := Vector2.ZERO
var _anchor_valid := false
# EXCHANGE_GC_ALREADY / LESS_GOLD 的线上的子头值(服务器 exchange.cpp)。
# GC_END 在成功与取消两种结局都会发,成败文案只走 CHAT_TYPE_INFO 聊天通道。
const NOTICE_ALREADY := 6
const NOTICE_LESS_GOLD := 7
var _was_active := false
var _notice_shown := false
var _end_note: Label
var _end_note_timer: Timer
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
proto = proto_node
ui = parent as CanvasLayer
_tooltip_builder = ItemTooltip.new()
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
_tooltip_bridge = ItemTooltipBridge.new()
_tooltip_bridge.setup(parent, proto, AssetRoot.path())
_build(parent)
if client.has_signal("exchange_changed"):
client.exchange_changed.connect(refresh)
func is_open() -> bool:
return _root != null and _root.visible
func get_mobile_window() -> Control:
return _root
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _root:
_apply_mobile_layout()
_refresh_mobile_inventory()
func close() -> void:
if _tooltip_bridge:
_tooltip_bridge.hide()
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
_next_display = 0
_offered_cells.clear()
_accept_pending = false
_anchor_valid = false
if _gold_input:
_gold_input.text = ""
func offer(inv_window: int, inv_cell: int) -> void:
if not is_open() or inv_window < 0 or inv_cell < 0:
return
var key := "%d:%d" % [inv_window, inv_cell]
if _offered_cells.has(key):
_status.text = "该物品已经放入交易"
return
if not _can_give(_item_at(inv_window, inv_cell)):
return
var x: Dictionary = client.get_exchange()
var used := {}
for item in x.get("self_items", []):
used[int(item.get("slot", -1))] = true
for i in 12:
if not used.has(i):
_next_display = i
break
if used.size() >= 12:
_status.text = "交易物品栏已满"
return
if client.exchange_add_item(inv_window, inv_cell, _next_display):
_offered_cells[key] = true
_next_display = (_next_display + 1) % 12
_status.text = ""
_refresh_mobile_inventory()
else:
_status.text = "交易请求发送失败"
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)
return "%s\n#%d ×%d" % [_name_of(vnum), vnum, int(item.get("count", 1))]
func refresh() -> void:
if client == null:
return
var x: Dictionary = client.get_exchange()
if not x.get("active", false):
if _was_active:
# 窗口开着时服务器关掉了交易(成功或取消,二者同发 GC_END)。
_show_end_note(int(x.get("notice", 0)))
_was_active = false
_notice_shown = false
_root.visible = false
_next_display = 0
_offered_cells.clear()
_accept_pending = false
_anchor_valid = false
_gold_input.text = ""
_status.text = ""
return
_was_active = true
_root.visible = true
_capture_anchor()
_render_self_grid(x.get("self_items", []))
_fill(_self_box, x.get("self_items", []))
_fill(_peer_box, x.get("peer_items", []))
_self_gold.text = "金币: %d" % int(x.get("self_gold", 0))
_peer_gold.text = "金币: %d" % int(x.get("peer_gold", 0))
var me := bool(x.get("self_accept", false))
var peer := bool(x.get("peer_accept", false))
# The server clears self_accept when either side changes the offer. Do not
# invent a local transaction state: mirror the authoritative bit exactly.
_accept_pending = me
_accept_btn.text = "已接受 ✓" if me else "接受"
_accept_btn.disabled = me
_accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1)
_root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受"
var notice := int(x.get("notice", 0))
if notice != 0 and not _notice_shown:
_notice_shown = true
_status.text = _notice_text(notice)
else:
_status.text = ""
_refresh_mobile_inventory()
func _notice_text(code: int) -> String:
match code:
NOTICE_ALREADY:
return "对方已经在交易中"
NOTICE_LESS_GOLD:
return "金币不足"
return ""
# 交易被服务器关闭时的一条短暂浮动提示(成败详情以 CHAT_TYPE_INFO 聊天为准)。
func _show_end_note(code: int) -> void:
if _root == null or _root.get_parent() == null:
return
if _end_note == null:
_end_note = Label.new()
_end_note.add_theme_font_size_override("font_size", 15)
_end_note.set_anchors_preset(Control.PRESET_CENTER_TOP)
_end_note.position = Vector2(-110, 130)
_end_note.size = Vector2(220, 24)
_end_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_root.get_parent().add_child(_end_note)
_end_note_timer = Timer.new()
_end_note_timer.one_shot = true
_end_note_timer.timeout.connect(func(): if _end_note: _end_note.visible = false)
add_child(_end_note_timer)
var extra := _notice_text(code)
_end_note.text = ("交易结束(%s" % extra) if extra != "" else "交易结束,结果见聊天提示"
_end_note.visible = true
_end_note_timer.start(5.0)
func _apply_mobile_layout() -> void:
if _root == null:
return
if _mobile_mode:
# The inventory candidate list is part of the same hosted surface, so it
# scales and receives the same safe-area treatment as the trade window.
_root.size = Vector2(688, 400)
_root.position = Vector2(-344, -200)
if _mobile_inventory_scroll:
_mobile_inventory_scroll.position = Vector2(452, 54)
_mobile_inventory_scroll.size = Vector2(224, 280)
_mobile_inventory_scroll.visible = true
else:
_root.size = Vector2(440, 360)
_root.position = Vector2(-220, -180)
if _mobile_inventory_scroll:
_mobile_inventory_scroll.position = Vector2.ZERO
_mobile_inventory_scroll.size = Vector2.ZERO
_mobile_inventory_scroll.visible = false
func _refresh_mobile_inventory() -> void:
if not _mobile_mode or _mobile_inventory_list == null or not is_open():
return
for child in _mobile_inventory_list.get_children():
child.queue_free()
var inventory: Array = client.get_inventory() if client and client.has_method("get_inventory") else []
var shown := 0
for item in inventory:
var cell := int(item.get("cell", -1))
var vnum := int(item.get("vnum", 0))
if cell < 0 or vnum <= 0:
continue
if _offered_cells.has("%d:%d" % [INVENTORY_WINDOW, cell]):
continue
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(214, 34)
if _tooltip_bridge:
_tooltip_bridge.bind(button, func() -> Dictionary: return item.duplicate(true))
var captured_cell := cell
button.pressed.connect(func() -> void: offer(INVENTORY_WINDOW, captured_cell))
_mobile_inventory_list.add_child(button)
shown += 1
if shown == 0:
var empty := Label.new()
empty.text = "(没有可交易物品)"
empty.add_theme_font_size_override("font_size", 12)
_mobile_inventory_list.add_child(empty)
func _item_at(inv_window: int, inv_cell: int) -> Dictionary:
if client == null:
return {}
if client.has_method("get_item"):
var direct: Dictionary = client.get_item(inv_window, inv_cell)
if not direct.is_empty():
return direct
if inv_window == INVENTORY_WINDOW and client.has_method("get_inventory"):
for item in client.get_inventory():
if int(item.get("cell", -1)) == inv_cell:
return item
if inv_window == 2 and client.has_method("get_equipment"):
var equipment: Array = client.get_equipment()
if inv_cell >= 0 and inv_cell < equipment.size():
return equipment[inv_cell]
return {}
func _can_give(item_data: Dictionary) -> bool:
var vnum := int(item_data.get("vnum", 0))
var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
var flags := int(item_data.get("anti_flags", 0)) | int(proto_data.get("anti_flags", 0))
if (flags & ITEM_ANTIFLAG_GIVE) != 0:
_status.text = "该物品不可交易"
return false
return true
func _render_self_grid(rows: Array) -> void:
if _self_grid == null:
return
for pos in _exchange_cells:
var cell: Button = _exchange_cells[pos]
cell.text = ""
cell.tooltip_text = ""
cell.set_meta("tooltip_item", {})
cell.set_meta("vnum", 0)
cell.set_meta("count", 0)
for row in rows:
var pos := int(row.get("slot", -1))
if not _exchange_cells.has(pos):
continue
var vnum := int(row.get("vnum", 0))
var count := int(row.get("count", 1))
var cell: Button = _exchange_cells[pos]
cell.text = "%s%s" % [_name_of(vnum).substr(0, 6), (" ×%d" % count) if count > 1 else ""]
cell.tooltip_text = ""
cell.set_meta("tooltip_item", row.duplicate(true))
cell.set_meta("vnum", vnum)
cell.set_meta("count", count)
func _drop_to_slot(payload: Dictionary, display_pos: int) -> bool:
if payload.is_empty() or display_pos < 0 or display_pos >= EXCHANGE_SLOT_COUNT:
return false
if bool(payload.get("money", false)):
if int(client.get_exchange().get("self_gold", 0)) > 0:
_status.text = "金币已经放入,不能修改"
return false
var amount := int(payload.get("count", 0))
if amount <= 0 or not client.has_method("exchange_add_gold"):
return false
return client.exchange_add_gold(amount)
var source_window := int(payload.get("window", -1))
var source_cell := int(payload.get("cell", -1))
if source_window not in [INVENTORY_WINDOW, 2] or source_cell < 0:
return false
var item_data := payload.duplicate(true)
var source_data := _item_at(source_window, source_cell)
for key in source_data:
if not item_data.has(key):
item_data[key] = source_data[key]
if not _can_give(item_data):
return false
for row in client.get_exchange().get("self_items", []):
if int(row.get("slot", -1)) == display_pos:
return false
var key := "%d:%d" % [source_window, source_cell]
if _offered_cells.has(key):
return false
if not client.has_method("exchange_add_item") or not client.exchange_add_item(
source_window, source_cell, display_pos):
_status.text = "交易请求发送失败"
return false
_offered_cells[key] = true
_refresh_mobile_inventory()
return true
func _fill(box: VBoxContainer, rows: Array) -> void:
for c in box.get_children():
c.queue_free()
for r in rows:
var l := Label.new()
l.text = "%s ×%d" % [_name_of(int(r.get("vnum", 0))), int(r.get("count", 1))]
if _tooltip_bridge:
_tooltip_bridge.bind(l, func() -> Dictionary: return r.duplicate(true))
l.add_theme_font_size_override("font_size", 12)
box.add_child(l)
func _on_put_gold() -> void:
var current := int(client.get_exchange().get("self_gold", 0))
if current > 0:
_status.text = "金币已经放入,不能修改"
return
var raw := _gold_input.text.strip_edges()
if raw == "" or not raw.is_valid_int():
_status.text = "请输入有效金币数量"
return
var g := int(raw)
if g <= 0 or g > 9999999:
_status.text = "金币数量必须在 19999999 之间"
return
if client.has_method("exchange_add_gold") and client.exchange_add_gold(g):
_gold_input.text = ""
_status.text = ""
else:
_status.text = "金币请求发送失败"
func _on_accept() -> void:
if _accept_pending or _accept_btn.disabled:
return
if client.has_method("exchange_accept") and client.exchange_accept():
_accept_pending = true
_accept_btn.disabled = true
else:
_status.text = "接受请求发送失败"
func _on_cancel() -> void:
if client and client.has_method("exchange_cancel"):
client.exchange_cancel()
close()
func _capture_anchor() -> void:
if _anchor_valid or client == null or not client.has_method("get_main_vid") \
or not client.has_method("get_entity"):
return
var entity: Dictionary = client.get_entity(int(client.get_main_vid()))
var p = entity.get("pos_cm", null)
if p is Vector3:
_anchor = Vector2(p.x, p.y)
_anchor_valid = true
elif entity.has("x") and entity.has("y"):
_anchor = Vector2(float(entity.get("x", 0)), float(entity.get("y", 0)))
_anchor_valid = true
func _current_position() -> Variant:
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
return null
var entity: Dictionary = client.get_entity(int(client.get_main_vid()))
var p = entity.get("pos_cm", null)
if p is Vector3:
return Vector2(p.x, p.y)
if entity.has("x") and entity.has("y"):
return Vector2(float(entity.get("x", 0)), float(entity.get("y", 0)))
return null
func _process(_dt: float) -> void:
if not is_open() or not _anchor_valid:
return
var current = _current_position()
if current is Vector2 and (absf(current.x - _anchor.x) > EXCHANGE_LIMIT_RANGE \
or absf(current.y - _anchor.y) > EXCHANGE_LIMIT_RANGE):
if client.has_method("exchange_cancel"):
client.exchange_cancel()
close()
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
_root.position = Vector2(-220, -180)
_root.size = Vector2(440, 360)
_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.07, 0.08, 0.1, 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 sl := Label.new()
sl.text = "我方"
sl.position = Vector2(20, 34)
_root.add_child(sl)
_self_box = VBoxContainer.new()
_self_box.position = Vector2(20, 56)
_self_box.custom_minimum_size = Vector2(200, 0)
_root.add_child(_self_box)
_self_grid = GridContainer.new()
_self_grid.columns = 6
_self_grid.position = Vector2(20, 138)
_self_grid.add_theme_constant_override("h_separation", 3)
_self_grid.add_theme_constant_override("v_separation", 3)
_root.add_child(_self_grid)
for pos in EXCHANGE_SLOT_COUNT:
var cell := Button.new()
cell.custom_minimum_size = Vector2(52, 32)
cell.add_theme_font_size_override("font_size", 8)
_self_grid.add_child(cell)
_exchange_cells[pos] = cell
if _tooltip_bridge:
cell.mouse_entered.connect(func(): _tooltip_bridge.show_item(cell.get_meta("tooltip_item", {})))
cell.mouse_exited.connect(func(): _tooltip_bridge.hide())
if item_mouse and item_mouse.has_method("register_target"):
item_mouse.register_target(cell,
func(payload: Dictionary): return _drop_to_slot(payload, pos), self)
_self_gold = Label.new()
_self_gold.text = "金币: 0"
_self_gold.position = Vector2(20, 250)
_root.add_child(_self_gold)
var pl := Label.new()
pl.text = "对方"
pl.position = Vector2(240, 34)
_root.add_child(pl)
_peer_box = VBoxContainer.new()
_peer_box.position = Vector2(240, 56)
_peer_box.custom_minimum_size = Vector2(180, 0)
_root.add_child(_peer_box)
_peer_gold = Label.new()
_peer_gold.text = "金币: 0"
_peer_gold.position = Vector2(240, 250)
_root.add_child(_peer_gold)
_status = Label.new()
_status.position = Vector2(20, 276)
_status.add_theme_font_size_override("font_size", 11)
_status.add_theme_color_override("font_color", Color(1, 0.65, 0.55))
_root.add_child(_status)
var pa := Label.new()
pa.name = "PeerAccept"
pa.text = "对方: 未接受"
pa.position = Vector2(240, 272)
pa.add_theme_font_size_override("font_size", 12)
_root.add_child(pa)
var bottom := HBoxContainer.new()
bottom.position = Vector2(20, 310)
_root.add_child(bottom)
_gold_input = LineEdit.new()
_gold_input.placeholder_text = "金币"
_gold_input.custom_minimum_size = Vector2(90, 0)
# Godot will open the native numeric keyboard on Android/iOS while the
# validation below remains authoritative for desktop and hardware keyboards.
_gold_input.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
bottom.add_child(_gold_input)
var put := Button.new()
put.text = "放金币"
put.pressed.connect(_on_put_gold)
bottom.add_child(put)
_accept_btn = Button.new()
_accept_btn.text = "接受"
_accept_btn.pressed.connect(_on_accept)
bottom.add_child(_accept_btn)
var cancel := Button.new()
cancel.text = "取消"
cancel.pressed.connect(_on_cancel)
bottom.add_child(cancel)
_mobile_inventory_scroll = ScrollContainer.new()
_mobile_inventory_scroll.name = "MobileInventory"
_mobile_inventory_scroll.visible = false
_mobile_inventory_list = VBoxContainer.new()
_mobile_inventory_list.add_theme_constant_override("separation", 4)
_mobile_inventory_scroll.add_child(_mobile_inventory_list)
_root.add_child(_mobile_inventory_scroll)
_apply_mobile_layout()