472 lines
15 KiB
GDScript
472 lines
15 KiB
GDScript
# 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)
|