# 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 = "金币数量必须在 1~9999999 之间" 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()