Files
mtgodot-poc/project/ui/private_shop_ui.gd
T

525 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.
# PrivateShopUI —— 개인상점 개설창 (CG_MYSHOP)1:1 迁移 `assets/root/uiprivateshopbuilder.py`
# 的 `PrivateShopBuilder`(布局走真 `assets/uiscript/uiscript/privateshopbuilder.py`)。
#
# var pu := preload("res://ui/private_shop_ui.gd").new()
# add_child(pu)
# pu.setup(m2client, ui_manager, proto, assets_root)
# pu.toggle()
#
# 布局:`privateshopbuilder.py`board + TitleBar + NameLine + ItemSlot 5×8=40 grid + Ok/Close)。
# 交互 = uiprivateshopbuilder.itemStock:左侧(挂在窗右边的附加面板)列背包候选,点一件
# “拿起”,再点右侧 40 格的空格 → 价格输入(`uiCommon.MoneyInputDialog`)→ 落位
# `AddPrivateShopItemStock` + `itemStock[targetSlot]=(src)`);点已占用格 → 撤下
# `OnSelectItemSlot` / `DelPrivateShopItemStock`)。OkButton(`BuildPrivateShop`)stock 按
# 格号排序、`display_pos = 格号`、上限 39`PRIVATE_SHOP_ITEM_MAX_NUM`)打包成
# `M2Client.open_private_shop(sign, [{vnum,count,inv_cell,price,display_pos}])`
# = `TPacketCGMyShop` + `TShopItemTable`×N)。40250 收摊用空的 `CG_MYSHOP`
# (服务端 `OpenMyShop` 会先关闭已有的个人摊位);`close_private_shop()` 已在
# M2Client classic 分支按该语义发送。
extends Node
const ItemTooltip = preload("res://ui/item_tooltip.gd")
class PrivateShopDragButton extends Button:
var drag_entry: Dictionary = {}
func _get_drag_data(_at_position: Vector2):
if drag_entry.is_empty():
return null
var preview := Label.new()
preview.text = String(text)
preview.add_theme_color_override("font_color", Color(1, 0.9, 0.55))
set_drag_preview(preview)
return {"private_shop_entry": drag_entry.duplicate(true)}
const PRIVATE_SHOP_ITEM_MAX := 39 # PRIVATE_SHOP_ITEM_MAX_NUM (TPacketCGMyShop::bCount 上限)
const SIGN_MAX := 25 # NameLine input_limit
const ITEM_ANTIFLAG_GIVE := 1 << 13
const ITEM_ANTIFLAG_MYSHOP := 1 << 16
var client: Node
var ui: CanvasLayer # UiManager
var proto: Node
var _tooltip_builder: RefCounted
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {}
var _sign: LineEdit
var _inv_list: VBoxContainer
var _cells: Dictionary = {} # 格号:int -> Panel
var _status: Label
var _stock: Dictionary = {} # 格号:int -> {cell, vnum, count, price}
var _picked = null # {cell, vnum, count} 等待落位
var _price_dialog: Control = null
var _server_prices: Dictionary = {} # vnum -> last price returned by MyShopPriceList
var item_mouse: Node
const INVENTORY_WINDOW := 1
var _mobile_mode := false
func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void:
client = m2client
ui = ui_manager
proto = proto_node
assets_root = assets
if assets_root == "" and ui and "assets_root" in ui:
assets_root = ui.assets_root
_tooltip_builder = ItemTooltip.new()
_tooltip_builder.setup(assets_root if assets_root != "" else AssetRoot.path(), "en", proto)
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(func(_w, _c): if is_open(): _refresh())
if client and client.has_signal("my_shop_price_list"):
client.my_shop_price_list.connect(_on_server_price)
if client and client.has_signal("private_shop_open_requested"):
client.private_shop_open_requested.connect(open)
# --- open / close --------------------------------------------------
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func get_mobile_window() -> Control:
return _win.get("root") if not _win.is_empty() else null
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if is_open():
_add_mobile_width(_win["root"])
_refresh()
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
_dismiss_price_dialog()
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 is_open():
var root: Control = _win["root"]
# An external mobile window is owned by UiManager's presentation stack;
# calling ui.close() here would call this method again through its close
# callback. Hide it and let UiManager restore the original parent.
if _mobile_mode and bool(root.get_meta("mobile_external", false)):
root.visible = false
else:
ui.close(root)
_win = {}
_cells.clear()
_stock.clear()
_picked = null
func open() -> void:
if is_open():
return
var path := uiscript_dir.path_join("privateshopbuilder.py")
if not FileAccess.file_exists(path):
push_warning("PrivateShopUI: no privateshopbuilder.py at " + path)
return
_stock.clear()
_picked = null
_win = ui.open_script(path, assets_root)
if not is_open():
return
var root: Control = _win["root"]
_index_cells(root)
_overlay_name_edit(root)
_add_inv_panel(root)
if _mobile_mode and ui and ui.has_method("reflow_mobile_window"):
ui.reflow_mobile_window(root)
_wire_buttons(root)
_refresh()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var n = _win.get("nodes", {}).get(nm, null)
return n if n is Control else null
func _index_cells(root: Node) -> void:
_cells.clear()
for n in root.find_children("slot_*", "Panel", true, false):
if not n.has_meta("slot_index"):
continue
var slot := int(n.get_meta("slot_index"))
_cells[slot] = n
var idx := slot
n.gui_input.connect(func(e: InputEvent) -> void:
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
_on_slot_clicked(idx)
elif e is InputEventScreenTouch and e.pressed:
_on_slot_clicked(idx))
# 同时支持从右侧背包候选区拖到空货位;点击选取仍保留给触屏/无鼠标场景。
n.set_drag_forwarding(Callable(self, "_no_drag_data"),
Callable(self, "_can_drop_slot").bind(idx),
Callable(self, "_drop_slot").bind(idx))
if item_mouse and item_mouse.has_method("register_target"):
item_mouse.register_target(n,
func(payload: Dictionary): return _drop_mouse_item(payload, idx), self)
func _drop_mouse_item(payload: Dictionary, slot: int) -> bool:
if payload.is_empty() or slot < 0 or slot >= 40 or _stock.has(slot):
return false
if int(payload.get("window", -1)) != INVENTORY_WINDOW:
return false
var cell := int(payload.get("cell", -1))
var vnum := int(payload.get("vnum", 0))
if cell < 0 or vnum <= 0:
return false
var entry := payload.duplicate(true)
entry["cell"] = cell
entry["vnum"] = vnum
entry["count"] = maxi(1, int(payload.get("count", 1)))
if not _can_stock(entry):
return false
_picked = entry
_ask_price(slot)
return true
# NameLine 在 uiscript 里是 textLabel)—— 盖一个 LineEdit 上去
func _overlay_name_edit(root: Control) -> void:
var name_line := _node("NameLine")
_sign = LineEdit.new()
_sign.max_length = SIGN_MAX
_sign.placeholder_text = "상점 이름"
if name_line:
name_line.visible = false
var slot := name_line.get_parent()
_sign.position = name_line.position
_sign.size = Vector2(maxf(name_line.size.x, 150), 18)
(slot if slot is Control else root).add_child(_sign)
else:
_sign.position = Vector2(13, 35)
_sign.size = Vector2(157, 18)
root.add_child(_sign)
func _wire_buttons(root: Control) -> void:
var ok := _node("OkButton")
if ok is BaseButton:
ok.pressed.connect(_ok)
var cl := _node("CloseButton")
if cl is BaseButton:
cl.pressed.connect(close)
var tb := _node("TitleBar")
if tb:
for b in tb.find_children("*", "BaseButton", true, false):
b.pressed.connect(close)
# 원 클라는 메인 인벤토리에서 드래그 —— 여기선 창 오른쪽에 후보 패널을 붙임
func _add_inv_panel(root: Control) -> void:
var panel := Panel.new()
panel.name = "inv_candidates"
panel.position = Vector2(root.size.x + 6, 0)
panel.size = Vector2(220, root.size.y)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.06, 0.06, 0.09, 0.95)
bg.border_color = Color(0.4, 0.36, 0.24, 0.9)
bg.set_border_width_all(1)
panel.add_theme_stylebox_override("panel", bg)
root.add_child(panel)
_add_mobile_width(root)
var title := Label.new()
title.text = "배낭 (클릭 → 집기)"
title.position = Vector2(10, 8)
title.add_theme_font_size_override("font_size", 12)
panel.add_child(title)
var sc := ScrollContainer.new()
sc.position = Vector2(8, 30)
sc.size = Vector2(204, root.size.y - 66)
panel.add_child(sc)
_inv_list = VBoxContainer.new()
_inv_list.add_theme_constant_override("separation", 3)
sc.add_child(_inv_list)
_status = Label.new()
_status.position = Vector2(10, root.size.y - 30)
_status.add_theme_font_size_override("font_size", 11)
_status.add_theme_color_override("font_color", Color(1, 0.9, 0.5))
panel.add_child(_status)
func _add_mobile_width(root: Control) -> void:
if not _mobile_mode or root == null:
return
# Keep the candidate list inside the hosted/scaled root instead of leaving
# it as an overflow panel outside the mobile safe area.
root.size.x = maxf(root.size.x, 674.0)
# --- 명칭 --------------------------------------------------------------
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:
_refresh_inv()
_refresh_grid()
func _refresh_inv() -> void:
if not is_instance_valid(_inv_list):
return
for c in _inv_list.get_children():
_inv_list.remove_child(c)
c.queue_free()
var used := {}
for s in _stock.values():
used[int(s["cell"])] = true
var inv: Array = client.get_inventory() if client and client.has_method("get_inventory") else []
var shown := 0
for it in inv:
var cell := int(it.get("cell", 0))
if used.has(cell):
continue
var vnum := int(it.get("vnum", 0))
var count := int(it.get("count", 1))
var b := PrivateShopDragButton.new()
b.text = "%s ×%d" % [_name_of(vnum), count]
if _tooltip_builder:
var pd: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
b.tooltip_text = _tooltip_builder.format(vnum, count, pd, it)
b.alignment = HORIZONTAL_ALIGNMENT_LEFT
b.custom_minimum_size = Vector2(196, 22)
b.toggle_mode = true
var entry: Dictionary = it.duplicate(true)
entry["cell"] = cell
entry["vnum"] = vnum
entry["count"] = count
entry["anti_flags"] = int(it.get("anti_flags", 0))
b.drag_entry = entry
b.pressed.connect(func() -> void: _pick(entry, b))
if _picked != null and int(_picked["cell"]) == cell:
b.button_pressed = true
_inv_list.add_child(b)
shown += 1
if shown == 0:
var l := Label.new()
l.text = "(배낭이 비어 있음)"
l.add_theme_font_size_override("font_size", 12)
_inv_list.add_child(l)
func _refresh_grid() -> void:
for slot in _cells.keys():
var cell: Panel = _cells[slot]
for ch in cell.get_children():
cell.remove_child(ch)
ch.queue_free()
cell.tooltip_text = ""
if _stock.has(slot):
var s: Dictionary = _stock[slot]
var nm := Label.new()
nm.text = _name_of(int(s["vnum"])).substr(0, 4)
nm.add_theme_font_size_override("font_size", 8)
nm.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(nm)
var pr := Label.new()
pr.text = str(int(s["price"]))
pr.position = Vector2(1, 18)
pr.add_theme_font_size_override("font_size", 8)
pr.add_theme_color_override("font_color", Color(0.95, 0.85, 0.5))
pr.mouse_filter = Control.MOUSE_FILTER_IGNORE
cell.add_child(pr)
var tooltip_item := s.duplicate(true)
tooltip_item["count"] = int(s["count"])
if _tooltip_builder:
var pd: Dictionary = proto.item(int(s["vnum"])) if proto and proto.has_method("item") else {}
cell.tooltip_text = _tooltip_builder.format(int(s["vnum"]), int(s["count"]), pd, tooltip_item)
else:
cell.tooltip_text = "%s ×%d\n%d 냥" % [_name_of(int(s["vnum"])), int(s["count"]), int(s["price"])]
cell.modulate = Color(1, 1, 1)
else:
cell.modulate = Color(0.72, 0.72, 0.82) if _picked != null else Color(1, 1, 1)
# --- 상호작용 --------------------------------------------------------
func _pick(entry: Dictionary, _btn: Button) -> void:
if not _can_stock(entry):
return
if _picked != null and int(_picked["cell"]) == int(entry["cell"]):
_picked = null
else:
_picked = entry
_refresh_grid()
func _no_drag_data(_at_position: Vector2):
return null
func _can_drop_slot(_at_position: Vector2, data, slot: int) -> bool:
return slot >= 0 and slot < 40 and not _stock.has(slot) \
and data is Dictionary and data.has("private_shop_entry")
func _drop_slot(_at_position: Vector2, data, slot: int) -> void:
if not _can_drop_slot(Vector2.ZERO, data, slot):
return
var entry: Dictionary = data["private_shop_entry"] as Dictionary
if not _can_stock(entry):
return
_picked = entry.duplicate(true)
_ask_price(slot)
func _source_item(cell: int) -> Dictionary:
if client == null:
return {}
if client.has_method("get_item"):
var direct: Dictionary = client.get_item(INVENTORY_WINDOW, cell)
if not direct.is_empty():
return direct
if client.has_method("get_inventory"):
for item in client.get_inventory():
if int(item.get("cell", -1)) == cell:
return item
return {}
func _can_stock(entry: Dictionary) -> bool:
var item_data := _source_item(int(entry.get("cell", -1)))
var vnum := int(entry.get("vnum", item_data.get("vnum", 0)))
var proto_data: Dictionary = proto.item(vnum) if proto and proto.has_method("item") else {}
var flags := int(entry.get("anti_flags", 0)) | int(item_data.get("anti_flags", 0)) \
| int(proto_data.get("anti_flags", 0))
if (flags & (ITEM_ANTIFLAG_GIVE | ITEM_ANTIFLAG_MYSHOP)) != 0:
if is_instance_valid(_status):
_status.text = "该物品不可摆摊"
return false
return true
func _on_slot_clicked(slot: int) -> void:
if _stock.has(slot):
_stock.erase(slot)
if is_instance_valid(_status):
_status.text = ""
_refresh()
return
if _picked == null:
return
_ask_price(slot)
func _ask_price(slot: int) -> void:
_dismiss_price_dialog()
var dlg := Panel.new()
dlg.set_anchors_preset(Control.PRESET_TOP_LEFT)
dlg.size = Vector2(240, 128)
dlg.position = (_win["root"].size - dlg.size) * 0.5
dlg.z_index = 200
dlg.mouse_filter = Control.MOUSE_FILTER_STOP
dlg.set_meta("mobile_modal", _mobile_mode)
# UiManager closes mobile modals before the feature window itself. Keep the
# controller's reference in sync when Android back/ESC hides this custom
# panel instead of going through the Cancel button.
dlg.set_meta("mobile_modal_close", Callable(self, "_dismiss_price_dialog"))
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.06, 0.06, 0.09, 0.98)
sb.border_color = Color(0.7, 0.55, 0.22, 0.9)
sb.set_border_width_all(1)
dlg.add_theme_stylebox_override("panel", sb)
var t := Label.new()
t.text = "판매 가격"
t.position = Vector2(12, 10)
dlg.add_child(t)
var default_price := int(_server_prices.get(int(_picked["vnum"]), 1))
if _stock.has(slot):
default_price = int(_stock[slot].get("price", default_price))
var price_input: Control
if _mobile_mode:
var edit := LineEdit.new()
edit.name = "PriceInput"
edit.text = str(default_price)
edit.placeholder_text = "输入价格"
edit.max_length = 10
edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
edit.position = Vector2(12, 40)
edit.size = Vector2(216, 32)
edit.text_submitted.connect(func(_text: String) -> void:
_place(slot, _parse_price(edit)))
price_input = edit
else:
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = 2000000000
spin.step = 1
spin.value = default_price
spin.position = Vector2(12, 40)
spin.size = Vector2(216, 28)
price_input = spin
dlg.add_child(price_input)
var ok := Button.new()
ok.text = "확인"
ok.position = Vector2(60, 86)
ok.pressed.connect(func() -> void:
_place(slot, _parse_price(price_input)))
dlg.add_child(ok)
var cancel := Button.new()
cancel.text = "취소"
cancel.position = Vector2(130, 86)
cancel.pressed.connect(_dismiss_price_dialog)
dlg.add_child(cancel)
_win["root"].add_child(dlg)
_price_dialog = dlg
func _parse_price(input: Control) -> int:
if input is SpinBox:
return int((input as SpinBox).value)
if input is LineEdit:
var raw := (input as LineEdit).text.strip_edges()
return int(raw) if raw.is_valid_int() else 0
return 0
func _dismiss_price_dialog() -> void:
if is_instance_valid(_price_dialog):
_price_dialog.queue_free()
_price_dialog = null
func _place(slot: int, price: int) -> void:
if slot < 0 or slot >= 40 or price <= 0 or price > 2000000000 or _picked == null:
_dismiss_price_dialog()
return
if not _can_stock(_picked):
_dismiss_price_dialog()
_picked = null
return
_stock[slot] = {"cell": int(_picked["cell"]), "vnum": int(_picked["vnum"]),
"count": int(_picked["count"]), "price": price}
_picked = null
_dismiss_price_dialog()
_refresh()
func _on_server_price(vnum: int, price: int) -> void:
if vnum > 0 and price >= 0:
_server_prices[vnum] = price
func _ok() -> void:
var sign := _sign.text.strip_edges()
if sign.is_empty() or sign.length() > SIGN_MAX:
if is_instance_valid(_status):
_status.text = "请输入 1%d 个字符的摊位名称" % SIGN_MAX
return
if _stock.is_empty():
if is_instance_valid(_status):
_status.text = "판매할 아이템을 올리세요"
return
var slots := _stock.keys()
slots.sort()
var items: Array = []
for slot in slots:
if items.size() >= PRIVATE_SHOP_ITEM_MAX:
break
var s: Dictionary = _stock[slot]
items.append({"vnum": s["vnum"], "count": s["count"], "inv_cell": s["cell"],
"price": s["price"], "display_pos": slot})
if client and client.has_method("open_private_shop"):
if client.open_private_shop(sign, items):
close()
elif is_instance_valid(_status):
_status.text = "摆摊请求发送失败"
func _close_shop() -> void:
if client and client.has_method("close_private_shop"):
client.close_private_shop()