# ShopUI (P8) —— NPC 商店窗。 # # 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)。 extends Node const CursorManager = preload("res://ui/cursor_manager.gd") const ItemTooltip = preload("res://ui/item_tooltip.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 var client: Node var proto: Node var item_list # ItemListDB (RefCounted) var _tooltip_builder: RefCounted var _root: Control var _tabbar: HBoxContainer var _grid: GridContainer var _err: Label var _sell_quantity: SpinBox var _active_tab := 0 var item_mouse: Node var cursor_manager: Node var audio: Node var _mode := 1 # 1=BUY, 2=SELL var _buy_mode_button: Button var _sell_mode_button: Button var _sell_drop_target: Button var _sell_confirm: ConfirmationDialog var _buy_confirm: ConfirmationDialog var _mobile_mode := false var _mobile_sell_scroll: ScrollContainer var _mobile_sell_list: VBoxContainer func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCounted = null) -> void: client = m2client proto = proto_node item_list = ilist _tooltip_builder = ItemTooltip.new() _tooltip_builder.setup(AssetRoot.path(), "en", proto) _build(parent) if client.has_signal("shop_opened"): client.shop_opened.connect(func(_v): open()) if client.has_signal("shop_closed"): client.shop_closed.connect(_close) if client.has_signal("shop_error"): client.shop_error.connect(_on_error) func is_open() -> bool: return _root != null and _root.visible func set_mobile_mode(enabled: bool) -> void: _mobile_mode = enabled if _root and _mobile_mode: _set_mode(_mode) func get_mobile_window() -> Control: return _root func close() -> void: _close() func open() -> void: _root.visible = true _err.text = "" _sell_quantity.value = 1 _active_tab = 0 _set_mode(1) refresh() 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() _root.visible = false _err.text = "" if _sell_quantity: _sell_quantity.value = 1 if is_instance_valid(_sell_confirm): _sell_confirm.queue_free() _sell_confirm = null if is_instance_valid(_buy_confirm): _buy_confirm.hide() _buy_confirm.queue_free() _buy_confirm = null _set_mode(1) func close_and_leave() -> void: client.shop_close() _close() func sell(inv_cell: int, count: int = -1) -> void: if not is_open() or inv_cell < 0: return var item := _inventory_item(inv_cell) var amount := count if count > 0 else int(_sell_quantity.value) if not item.is_empty(): amount = clampi(amount, 1, int(item.get("count", amount))) else: amount = clampi(amount, 1, 200) var payload := item.duplicate(true) payload["window"] = 1 payload["cell"] = inv_cell payload["vnum"] = int(item.get("vnum", 0)) payload["count"] = int(item.get("count", amount)) _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: return false var cell := int(payload.get("cell", -1)) if cell < 0 or not client.has_method("shop_sell"): return false var source_count := maxi(1, int(payload.get("count", 1))) var amount := clampi(int(_sell_quantity.value), 1, mini(200, source_count)) return _request_sell(payload, amount) func _inventory_item(cell: int) -> Dictionary: if client == null or not client.has_method("get_inventory"): return {} for item in client.get_inventory(): if int(item.get("cell", -1)) == cell: return item return {} func _proto_item(vnum: int) -> Dictionary: if vnum > 0 and proto and proto.has_method("item"): return proto.item(vnum) return {} func _sell_price(vnum: int, count: int, item_data: Dictionary = {}) -> int: var d := item_data if not item_data.is_empty() else _proto_item(vnum) var unit := maxi(0, int(d.get("sell_price", 0))) var flags := int(d.get("flags", 0)) var raw := (count / maxi(1, unit)) if (flags & ITEM_FLAG_COUNT_PER_1GOLD) != 0 else unit * count return maxi(0, int(raw) / 5) func _is_valuable(payload: Dictionary, item_data: Dictionary) -> bool: if int(item_data.get("sell_price", 0)) > 5000: return true var sockets: Array = payload.get("sockets", []) for value in sockets: if int(value) != 0: return true return false func _request_sell(payload: Dictionary, amount: int) -> bool: var vnum := int(payload.get("vnum", 0)) var d := _proto_item(vnum) var anti_flags := int(payload.get("anti_flags", 0)) | int(d.get("anti_flags", 0)) if (anti_flags & ITEM_ANTIFLAG_SELL) != 0: _on_error("CANNOT_SELL") _play_ui("loginfail.wav") return false if _is_valuable(payload, d): _ask_sell_confirmation(payload, amount, _sell_price(vnum, amount, d)) return true return _send_sell(int(payload.get("cell", -1)), amount) func _send_sell(cell: int, amount: int) -> bool: if cell < 0 or not client.has_method("shop_sell"): return false if not client.shop_sell(cell, amount): _on_error("SEND_FAILED") return false _play_ui("money.wav") return true func _ask_sell_confirmation(payload: Dictionary, amount: int, price: int) -> void: if is_instance_valid(_sell_confirm): _sell_confirm.hide() _sell_confirm.queue_free() _sell_confirm = ConfirmationDialog.new() _sell_confirm.title = "确认出售" _sell_confirm.dialog_text = "出售 %d 个物品,获得 %d 金币?" % [amount, price] _sell_confirm.ok_button_text = "出售" _sell_confirm.cancel_button_text = "取消" var dialog := _sell_confirm dialog.confirmed.connect(func() -> void: _sell_confirm = null dialog.hide() dialog.queue_free() _send_sell(int(payload.get("cell", -1)), amount)) dialog.canceled.connect(func() -> void: _sell_confirm = null dialog.hide() dialog.queue_free()) _prepare_mobile_confirmation(dialog, "确认出售") _root.add_child(dialog) if dialog.is_inside_tree(): _popup_confirmation(dialog) else: if _mobile_mode: dialog.call_deferred("popup_centered", Vector2i(480, 220)) else: dialog.call_deferred("popup_centered") func _play_ui(name: String) -> void: if audio and audio.has_method("play_ui"): audio.play_ui(name) func _set_mode(mode: int) -> void: _mode = 2 if mode == 2 else 1 if _buy_mode_button: _buy_mode_button.button_pressed = _mode == 1 if _sell_mode_button: _sell_mode_button.button_pressed = _mode == 2 if _sell_drop_target: _sell_drop_target.visible = _mode == 2 if cursor_manager and cursor_manager.has_method("set_cursor"): cursor_manager.set_cursor(CursorManager.SELL if _mode == 2 else CursorManager.BUY) if _mobile_sell_scroll: _mobile_sell_scroll.visible = _mobile_mode and _mode == 2 _grid.visible = not (_mobile_mode and _mode == 2) _refresh_mobile_sell() 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 refresh() -> void: if not is_open() or client == null: return for c in _tabbar.get_children(): _tabbar.remove_child(c) c.queue_free() for c in _grid.get_children(): _grid.remove_child(c) c.queue_free() # START_EX 商店有多个货架(shop.GetTabCount);普通 START 商店 tabs 只 1 个。 var tabs: Array = [] 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 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) tb.button_pressed = (i == _active_tab) var idx := i tb.pressed.connect(func() -> void: _active_tab = idx refresh()) _tabbar.add_child(tb) else: _tabbar.visible = false var items: Array = tabs[_active_tab].get("items", []) # uishop.py: 买位置 = tabIdx * SHOP_SLOT_COUNT + slotPos var base := _active_tab * SHOP_SLOT_COUNT var by_pos := {} for it in items: 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)) _refresh_mobile_sell() func _refresh_mobile_sell() -> void: if not _mobile_mode or _mobile_sell_list == null: return for child in _mobile_sell_list.get_children(): child.queue_free() var inv: Array = client.get_inventory() if client and client.has_method("get_inventory") else [] if inv.is_empty(): var empty := Label.new() empty.text = "(背包为空)" _mobile_sell_list.add_child(empty) return for item in inv: var cell := int(item.get("cell", -1)) var vnum := int(item.get("vnum", 0)) if cell < 0 or vnum <= 0: 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(330, 30) button.pressed.connect(func(): sell(cell, int(_sell_quantity.value))) _mobile_sell_list.add_child(button) func _slot(it: Dictionary, pos: int) -> Button: var slot := Button.new() slot.custom_minimum_size = Vector2(66, 30) slot.clip_text = true 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 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)) else: slot.disabled = true return slot func _buy_slot(pos: int, item: Dictionary) -> void: if _mode != 1: _set_mode(1) return if item.is_empty() or pos < 0: return if is_instance_valid(_buy_confirm): _buy_confirm.hide() _buy_confirm.queue_free() _buy_confirm = ConfirmationDialog.new() _buy_confirm.title = "确认购买" _buy_confirm.dialog_text = "购买 %s?\n价格:%d 金币" % [_name_of(int(item.get("vnum", 0))), int(item.get("price", 0))] _buy_confirm.ok_button_text = "购买" _buy_confirm.cancel_button_text = "取消" var dialog := _buy_confirm dialog.confirmed.connect(func() -> void: _buy_confirm = null dialog.hide() dialog.queue_free() if not client.shop_buy(pos, 1): _on_error("SEND_FAILED")) dialog.canceled.connect(func() -> void: _buy_confirm = null dialog.hide() dialog.queue_free()) _prepare_mobile_confirmation(dialog, "确认购买") _root.add_child(dialog) if dialog.is_inside_tree(): _popup_confirmation(dialog) else: if _mobile_mode: dialog.call_deferred("popup_centered", Vector2i(480, 220)) else: dialog.call_deferred("popup_centered") func _prepare_mobile_confirmation(dialog: ConfirmationDialog, title: String) -> void: if not _mobile_mode: return dialog.set_meta("mobile_modal", true) dialog.set_meta("mobile_title", title) dialog.min_size = Vector2i(480, 220) dialog.size = Vector2i(480, 220) func _popup_confirmation(dialog: ConfirmationDialog) -> void: if _mobile_mode: dialog.popup_centered(Vector2i(480, 220)) else: dialog.popup_centered() func _on_error(kind: String) -> void: var tbl := { "NOT_ENOUGH_MONEY": "金币不足", "SOLDOUT": "已售罄", "INVENTORY_FULL": "背包已满", "INVALID_POS": "位置无效", "NOT_ENOUGH_ITEM": "物品数量不足", "CANNOT_BUY": "当前物品不可购买", "CANNOT_SELL": "当前物品不可出售", "SHOP_BUSY": "商店正在处理上一笔交易", "SEND_FAILED": "交易请求发送失败", } _err.text = str(tbl.get(kind, kind)) 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.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) _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) _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) _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) _sell_mode_button.pressed.connect(func() -> void: _set_mode(2)) _root.add_child(_sell_mode_button) _tabbar = HBoxContainer.new() _tabbar.position = Vector2(12, 30) _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) _mobile_sell_scroll = ScrollContainer.new() _mobile_sell_scroll.position = Vector2(12, 58) _mobile_sell_scroll.size = Vector2(350, 252) _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)