Files
mtgodot-poc/project/ui/exchange_ui.gd
T
shenleiandClaude Code 8092ff44e2 装备校验链/快捷栏/公会/好友情侣/交易仓库五项差距修复(增量 129)
- 装备校验:can_equip 按 CanEquipNow→FindEquipCell→EquipItem 精确重排,
  新增 find_equip_cell() 1:1 复刻(RING/UNIQUE 占用翻转、COSTUME/BELT/DS),
  帝国 antiflag 降级为 tooltip 提示,LIMIT_PCBANG/REAL_TIME* 语义与倒计时文案
- 快捷栏:死亡全槽禁用+激活守卫、变身(affect 220)技能置灰、
  物品槽失配置灰(服务器回收/换武器后刷新)
- 公会:GUILD_AUTH_* 权限位门控踢人/公告/技能升级,/gskillup、
  被宣战应答弹窗(/war·/nowar)、资金存取行(40250 服务端已禁用仍保留 1:1)
- 好友/情侣:messenger 三固定组(好友/公会/情侣),lover_login/logout/
  near/far/divorce 命令事件→在线/在身边状态与离婚隐藏
- 交易/仓库:ExchangeState.last_notice(ALREADY/LESS_GOLD)结束浮层提示;
  仓库改密 /safebox_change_password 三段输入;关窗补 /safebox_close·/mall_close;
  仓库金钱经核实 40250 无 wire 协议,UI 定界只读
- app_flow:重连时 guild_marks_ready 重复连接加 is_connected 守卫
- 相机 look_at 零向量守卫(首帧/传送落点)
- 测试:equip/love/guild 断言更新,79 项中 78 绿(game_camera_test 为
  HEAD 既有失败);真服冒烟 LIVE_SMOKE PASS(192.168.21.203 全链路)
- docs/CLIENT-GAP.md 同步增量 129

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kUFvQGYusuY3dkfGitmAe
2026-09-07 21:43:30 +09:00

515 lines
17 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")
var client: Node
var proto: Node
var _tooltip_builder: RefCounted
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
_tooltip_builder = ItemTooltip.new()
_tooltip_builder.setup(AssetRoot.path(), "en", proto)
_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 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)
button.tooltip_text = _tooltip_for(item)
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("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 = _tooltip_for(row)
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))]
l.tooltip_text = _tooltip_for(r)
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 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()